Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
4b6186b
feat(textarea): support floating labels with slotted content
brandyscarney Aug 3, 2026
bbcc734
test(textarea): rename tests and add fill and rtl coverage
brandyscarney Aug 3, 2026
2662a5c
chore(): remove old snapshots
brandyscarney Aug 3, 2026
5efd430
chore(): add updated snapshots
Ionitron Aug 3, 2026
667da5c
fix(textarea): target buttons specifically for smaller margin top
brandyscarney Aug 3, 2026
450d154
chore(): add updated snapshots
Ionitron Aug 4, 2026
86bdbf7
docs(breaking): add a section for textarea structural changes
brandyscarney Aug 5, 2026
4f02aa9
copy editing
brandyscarney Aug 5, 2026
bf98a5d
docs(breaking): add a note about the min height change
brandyscarney Aug 5, 2026
c25a43b
Merge branch 'major-9.0' into FW-6471-textarea
brandyscarney Aug 5, 2026
3c75c3f
fix(textarea): disable the label transition when start or end slots a…
brandyscarney Aug 6, 2026
c5c726f
trigger actions after outage
brandyscarney Aug 7, 2026
58708ca
fix(textarea): allow click propagation for slotted content
brandyscarney Aug 7, 2026
a595a64
refactor(textarea): mark slot var as internal and add fallback
brandyscarney Aug 7, 2026
6278723
refactor(textarea): call raf from utils and cancel it in disconnected…
brandyscarney Aug 7, 2026
81c0d55
refactor(textarea): move the start container width logic to a controller
brandyscarney Aug 10, 2026
842a0f6
test(textarea): add tests for label-floating and skip-label-transitio…
brandyscarney Aug 11, 2026
818d4e0
fix(textarea): merge duplicate styles and fix padding for autogrow
brandyscarney Aug 11, 2026
72b587a
test(textarea): update to include floating autogrow example in slot
brandyscarney Aug 11, 2026
6f8aebe
chore(): add updated snapshots
brandyscarney Aug 11, 2026
ce36856
fix(textarea): stretch native textarea to fill set height
brandyscarney Aug 11, 2026
4d3edc8
chore(): add updated snapshots
brandyscarney Aug 11, 2026
0145d69
refactor(textarea): remove outdated skipLabelTransition State
brandyscarney Aug 11, 2026
1d1cced
refactor(textarea): remove old code and update outline container render
brandyscarney Aug 11, 2026
b1cebc0
docs(textarea): update breaking changes to clarify structural changes
brandyscarney Aug 12, 2026
ba162df
test(textarea): add missing awaits
brandyscarney Aug 12, 2026
f5763ec
test(textarea): split functional checks and improve comparisons
brandyscarney Aug 12, 2026
958f88a
fix(textarea): recalculate start container width on render
brandyscarney Aug 12, 2026
97fa51a
fix(textarea): position the label relative to textarea-wrapper to avo…
brandyscarney Aug 12, 2026
143fb20
fix(textarea): do not propagate clicks for non-slotted content
brandyscarney Aug 13, 2026
7ea840a
fix(textarea): emit a single click when pressing a slotted textarea
brandyscarney Aug 13, 2026
df9d9ef
fix(textarea): properly ellipsis labels when slot content is wide
brandyscarney Aug 13, 2026
2b5bccd
test(textarea): add test and screenshots for overflow behavior
brandyscarney Aug 13, 2026
014db57
fix(textarea): add min-height back for ios
brandyscarney Aug 13, 2026
b5f498b
docs(breaking): update textarea min height to include more details
brandyscarney Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
61 changes: 61 additions & 0 deletions BREAKING.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ This is a comprehensive list of the breaking changes introduced in the major ver
- [Router Outlet](#version-9x-router-outlet)
- [Searchbar](#version-9x-searchbar)
- [Select](#version-9x-select)
- [Textarea](#version-9x-textarea)
- [Framework Specific](#version-9x-framework-specific)
- [Angular](#version-9x-angular)
- [React](#version-9x-react)
Expand Down Expand Up @@ -229,6 +230,66 @@ When using `interface="action-sheet"`, `ion-select` no longer assigns the `selec

Previously, the `selected` role was assigned only to the option matching the select's current value. Because the dismiss role mirrors the tapped button, this surfaced in just one case: re-selecting the already-selected option dismissed the action sheet with `role: "selected"` in `ionActionSheetDidDismiss`. Tapping any other option changed the value and dismissed with `role: ""`. Now that the role is no longer assigned, both cases dismiss with `role: undefined`. Apps that inspected this role to detect that a value was chosen, such as reading `role` from the underlying action sheet's `onDidDismiss` result, should listen for `ion-select`'s `ionChange` event instead, which emits the selected value when the selection changes.

<h4 id="version-9x-textarea">Textarea</h4>

**Floating Label Behavior**

Floating labels no longer automatically float when the textarea contains slotted content. Labels float only when the textarea is focused or has a value.

**Internal DOM Structure Changes**

The internal DOM structure has been reorganized to support floating labels with slotted content.

Removed: `.textarea-wrapper-inner`

Added: `.textarea-control`

Renamed:
- `.start-slot-wrapper` → `.textarea-start`
- `.end-slot-wrapper` → `.textarea-end`

Restructured:
- `.label-text-wrapper` moved from `.textarea-wrapper-inner` into `.textarea-control`
- `.native-wrapper` moved from `.textarea-wrapper-inner` into `.textarea-control`
- `.start-slot-wrapper` moved from `.textarea-wrapper-inner` to `.textarea-wrapper` and was renamed `.textarea-start`
- `.end-slot-wrapper` moved from `.textarea-wrapper-inner` to `.textarea-wrapper` and was renamed `.textarea-end`

Update your selectors to account for these structural changes:

```diff
-ion-textarea .textarea-wrapper-inner .native-wrapper { }
+ion-textarea .textarea-control .native-wrapper { }

-ion-textarea .start-slot-wrapper [slot="start"] { }
+ion-textarea .textarea-start [slot="start"] { }

-ion-textarea .end-slot-wrapper [slot="end"] { }
+ion-textarea .textarea-end [slot="end"] { }
```

**Minimum Height Change**

The minimum height of textarea in Material Design (`md` mode) is now `72px`. At the default number of rows this makes textareas the same height regardless of the `fill` property or `labelPlacement`. Previously the minimum height was:

| Fill | Label placement | Previous minimum height |
| --- | --- | --- |
| default | `start`, `end`, `fixed` | `44px` |
| default | `floating`, `stacked` | `56px` |
| `solid`, `outline` | any | `56px` |

These were minimums, not the heights textareas actually rendered at. A textarea with content in the `start` or `end` slots was already taller than its minimum, so the change affects it differently. For example, a `fill="solid"` textarea with slotted icons and buttons previously rendered at `72px` with a `start` label and `81px` with a `floating` label. Both are now `72px`, so that floating label case is `9px` shorter than before rather than taller.

Because `72px` is taller than two rows of text, `rows` values below `3` no longer change the height of the textarea in `md` mode: `rows="1"` and `rows="2"` both render at `72px`.

If you were relying on the previous heights, or you need `rows` to control the height, override the minimum height back. The override has to be more specific than the component's own style, so a bare `ion-textarea` selector will not apply. Add a custom class to the textarea to increase specificity:

```css
/* Add a custom class to the textarea */
ion-textarea.custom {
min-height: 44px;
}
```

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

<h4 id="version-9x-angular">Angular</h4>
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
224 changes: 220 additions & 4 deletions core/src/components/textarea/test/basic/textarea.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,37 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';

/**
* This behavior does not vary across directions
*/
configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('textarea: basic'), () => {
test('should stretch to fill height when min-height is set on the host', async ({ page }) => {
await page.setContent(
`
<ion-textarea label="Label" label-placement="floating" style="min-height: 150px"></ion-textarea>
`,
config
);

const textarea = page.locator('ion-textarea');
const control = page.locator('ion-textarea .textarea-control');

// Get the height of the host
const textareaHeight = await textarea.evaluate((el) => el.clientHeight);

// Get the height of the textarea control
const controlHeight = await control.boundingBox().then((el) => el?.height);

// The height of the host and control should be the same
expect(textareaHeight).toBe(controlHeight);
});
});
});

/**
* This behavior does not vary across modes/directions
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('textarea: click'), () => {
test('should trigger onclick only once when clicking the label', async ({ page }, testInfo) => {
Expand All @@ -9,15 +40,23 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
description: 'https://github.com/ionic-team/ionic-framework/issues/30165',
});
// Create a spy function in page context
await page.setContent(`<ion-textarea label="Textarea"></ion-textarea>`, config);
await page.setContent(
`
<ion-textarea
label="Click Me"
value="Test Value"
></ion-textarea>
`,
config
);

// Track calls to the exposed function
const clickEvent = await page.spyOnEvent('click');
const input = page.locator('label.textarea-wrapper');
const textarea = page.locator('label.textarea-wrapper');

// Use position to make sure we click into the label enough to trigger
// what would be the double click
await input.click({
await textarea.click({
position: {
x: 5,
y: 5,
Expand All @@ -27,9 +66,186 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
// Verify the click was triggered exactly once
expect(clickEvent).toHaveReceivedEventTimes(1);

// Verify that the event target is the checkbox and not the item
// Verify that the event target is the textarea and not the item
const event = clickEvent.events[0];
expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-textarea');
});

test('should trigger onclick only once when clicking the wrapper', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/30165',
});
// Create a spy function in page context
await page.setContent(
`
<ion-textarea
label="Click Me"
value="Test Value"
label-placement="floating"
></ion-textarea>
`,
config
);

// Track calls to the exposed function
const clickEvent = await page.spyOnEvent('click');
const textarea = page.locator('div.native-wrapper');

// Use position to make sure we click into the label enough to trigger
// what would be the double click
await textarea.click({
position: {
x: 1,
y: 1,
},
});

// Verify the click was triggered exactly once
expect(clickEvent).toHaveReceivedEventTimes(1);

// Verify that the event target is the textarea and not the item
const event = clickEvent.events[0];
expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-textarea');
});

test('should trigger onclick only once when the textarea is itself slotted', async ({ page }) => {
await page.setContent(
`
<ion-item>
<ion-textarea slot="end" label="Click Me" value="Test Value"></ion-textarea>
</ion-item>
`,
config
);

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

await page.locator('label.textarea-wrapper').click({
position: {
x: 5,
y: 5,
},
});

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

test('should propagate clicks from start slot button to parent', async ({ page }) => {
await page.setContent(
`
<div id="parent" onclick="window.parentClicks = (window.parentClicks || 0) + 1">
Parent Container
<ion-textarea value="test@ionic.io" label="Email">
<ion-button slot="start" onclick="window.buttonClicks = (window.buttonClicks || 0) + 1">Icon</ion-button>
</ion-textarea>
</div>
`,
config
);

const button = page.locator('ion-button[slot="start"]');
const parent = page.locator('#parent');

// Click the button in the start slot
await button.click();

// The button's own click handler should have fired
let buttonClicks = await page.evaluate(() => (window as any).buttonClicks);
expect(buttonClicks).toBe(1);

// The parent's click handler should also have fired
let parentClicks = await page.evaluate(() => (window as any).parentClicks);
expect(parentClicks).toBe(1);

// Click on the parent container (far right to avoid the start button)
await parent.click({ position: { x: 250, y: 50 } });

// Parent should have incremented
parentClicks = await page.evaluate(() => (window as any).parentClicks);
expect(parentClicks).toBe(2);

// Button should NOT have incremented
buttonClicks = await page.evaluate(() => (window as any).buttonClicks);
expect(buttonClicks).toBe(1);
});

test('should propagate clicks from end slot button to parent', async ({ page }) => {
await page.setContent(
`
<div id="parent" onclick="window.parentClicks = (window.parentClicks || 0) + 1">
Parent Container
<ion-textarea value="test@ionic.io" label="Email">
<ion-button slot="end" onclick="window.buttonClicks = (window.buttonClicks || 0) + 1">Toggle</ion-button>
</ion-textarea>
</div>
`,
config
);

const button = page.locator('ion-button[slot="end"]');
const parent = page.locator('#parent');

// Click the button in the end slot
await button.click();

// The button's own click handler should have fired
let buttonClicks = await page.evaluate(() => (window as any).buttonClicks);
expect(buttonClicks).toBe(1);

// The parent's click handler should also have fired
let parentClicks = await page.evaluate(() => (window as any).parentClicks);
expect(parentClicks).toBe(1);

// Click on the parent container (far left to avoid the end button)
await parent.click({ position: { x: 10, y: 50 } });

// Parent should have incremented
parentClicks = await page.evaluate(() => (window as any).parentClicks);
expect(parentClicks).toBe(2);

// Button should NOT have incremented
buttonClicks = await page.evaluate(() => (window as any).buttonClicks);
expect(buttonClicks).toBe(1);
});
});
});

/**
* The solid and outline fills are only supported by `md` mode. These
* are the only fills that get padding which can cause a double click.
*/
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('textarea: click'), () => {
['solid', 'outline'].forEach((fill) => {
test(`should trigger onclick only once when clicking the ${fill} wrapper padding`, async ({ page }) => {
await page.setContent(
`
<ion-textarea
label="Click Me"
value="Test Value"
label-placement="floating"
fill="${fill}"
></ion-textarea>
`,
config
);

const clickEvent = await page.spyOnEvent('click');
const wrapper = page.locator('label.textarea-wrapper');

await wrapper.click({
position: {
x: 5,
y: 5,
},
});

expect(clickEvent).toHaveReceivedEventTimes(1);

const event = clickEvent.events[0];
expect((event.target as HTMLElement).tagName.toLowerCase()).toBe('ion-textarea');
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,61 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
const textarea = page.locator('ion-textarea');
await expect(textarea).toHaveScreenshot(screenshot(`textarea-label-slot-truncate`));
});

/**
* The floating label must be positioned relative to `.textarea-wrapper` so
* its width is not constrained when the textarea width collapses. These
* tests cover both cases: a long label should retain the same available
* width despite wide start content, and a short label should not collapse
* when the start content takes up most of the textarea's width.
*/
test('start slot content should not shrink the label', async ({ page }) => {
await page.setContent(
`
<div style="width: 200px">
<ion-textarea id="plain" fill="outline" label-placement="floating" value="x" label="Email Email Email Email Email Email"></ion-textarea>
<ion-textarea id="wide-start" fill="outline" label-placement="floating" value="x" label="Email Email Email Email Email Email">
<div slot="start" style="width: 120px; height: 24px"></div>
</ion-textarea>
</div>
`,
config
);

const labelWidth = (id: string) =>
page.locator(`${id} .label-text`).evaluate((el: HTMLElement) => ({
available: el.clientWidth,
wanted: el.scrollWidth,
}));

const plain = await labelWidth('#plain');
const wideStart = await labelWidth('#wide-start');

// The label is long enough that it has to truncate in both cases
expect(plain.wanted).toBeGreaterThan(plain.available);

expect(wideStart.available).toBe(plain.available);
});

test('start slot content should not collapse a short label', async ({ page }) => {
await page.setContent(
`
<div style="width: 200px">
<ion-textarea fill="outline" label-placement="floating" value="x" label="Email">
<div slot="start" style="width: 170px; height: 24px"></div>
</ion-textarea>
</div>
`,
config
);

const label = await page.locator('.label-text').evaluate((el: HTMLElement) => ({
available: el.clientWidth,
wanted: el.scrollWidth,
}));

expect(label.available).toBe(label.wanted);
});
});
});

Expand Down
Loading
Loading