Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 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
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
  •  
  •  
  •  
19 changes: 19 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,24 @@ 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>

**Internal DOM Structure Changes**

The internal DOM structure has been modified to support floating labels with slotted start and end content. Additionally, the structure of the component has been reorganized, with some elements now grouped differently than before. This may introduce breaking changes for developers who rely on the component's internal DOM structure or apply custom styling to internal elements.

The following internal elements have been modified:
- Removed: `<div class="textarea-wrapper-inner">`
- Renamed: `<div class="start-slot-wrapper">` is now `<div class="textarea-start">`
- Added: `<div class="textarea-control">` wrapper for the label and native control
- Renamed: `<div class="end-slot-wrapper">` is now `<div class="textarea-end">`

While the public API has not changed, selectors or style overrides targeting the previous markup will need to be updated to reference the new element names and their organization. If you have custom CSS targeting the internal structure of textarea, update your selectors to account for these structural changes.

**Minimum Height Change**

The minimum height of textarea in Material Design (`md` mode) has been increased from `56px` to `72px`. This change ensures consistent heights across textareas regardless of the `fill` property or `labelPlacement`, providing a more uniform and predictable user experience. If you were relying on textareas being `56px` tall or had custom CSS based on that value, you will need to either update your styles to accommodate the new `72px` height or override it back to `56px` if needed.

<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.
163 changes: 159 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,125 @@ 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 propagate clicks from start slot button to parent', async ({ page }) => {
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 }) => {
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);
});
});
});
Loading
Loading