Skip to content

fix(textarea): support floating labels with slotted content - #31321

Open
brandyscarney wants to merge 15 commits into
major-9.0from
FW-6471-textarea
Open

fix(textarea): support floating labels with slotted content#31321
brandyscarney wants to merge 15 commits into
major-9.0from
FW-6471-textarea

Conversation

@brandyscarney

@brandyscarney brandyscarney commented Aug 3, 2026

Copy link
Copy Markdown
Member

Issue number: N/A


What is the current behavior?

Textareas with a floating label and a start or end slot always display the label in the floated state, regardless of whether the textarea contains a value:

without value with value
without-value with-value

What is the new behavior?

  • The floating label now behaves consistently regardless of whether start or end slots are present:
    • It overlays the textarea when the field is empty.
    • It floats when the textarea is focused or contains a value.
  • The height of textareas has been increased to ensure consistent sizing among all textareas.
  • Start slot content is now always positioned to the left of both the label and the input.
  • Additional screenshot tests have been added to verify these behavior and layout changes.
  • A follow-up ticket has been created to address the remaining UI differences between our leading/trailing content implementation and the md specification.

Does this introduce a breaking change?

  • Yes
  • No

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.

Other information

Preview

@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ionic-framework Ready Ready Preview Aug 7, 2026 9:18pm

Request Review

@brandyscarney
brandyscarney changed the base branch from main to major-9.0 August 3, 2026 21:17
@github-actions github-actions Bot added package: core @ionic/core package package: angular @ionic/angular package package: vue @ionic/vue package package: react @ionic/react package labels Aug 3, 2026
<slot name="start"></slot>
</div>
{hasOutlineFill && <div class="textarea-outline-container">{this.renderOutlineDecorations()}</div>}
<div class="textarea-start">

@brandyscarney brandyscarney Aug 3, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I renamed the slot wrapper divs from the following:

  • start-slot-wrappertextarea-start
  • end-slot-wrappertextarea-end

The goal was to align the naming with the wrapper elements added to Input.

This also matches our existing naming pattern with elements like input-bottom and textarea-bottom.

@github-actions github-actions Bot removed package: angular @ionic/angular package package: vue @ionic/vue package package: react @ionic/react package labels Aug 3, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I renamed these screenshots from textarea-slots to textarea-slot to match the folder name.


configs().forEach(({ title, screenshot, config }) => {
test.describe(title('textarea: start and end slots (visual checks)'), () => {
test.describe(title('textarea: slot'), () => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was updated to match the folder name, following how we title other tests.

@brandyscarney
brandyscarney marked this pull request as ready for review August 7, 2026 14:35
@brandyscarney
brandyscarney requested a review from a team as a code owner August 7, 2026 14:35
@brandyscarney
brandyscarney requested a review from gnbm August 7, 2026 14:35

@ShaneK ShaneK left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking really good! I found a few things that I believe are problems, please let me know if I'm off base here!

// ----------------------------------------------------------------

.textarea-control {
@include padding(var(--padding-top), 0px, var(--padding-bottom), 0px);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The padding moved here but .native-wrapper::after still has its own copy, so auto-grow counts it twice. An auto-grow textarea with 3 lines comes out 26px taller than the same content with rows="3" on md, and 18px on ios, which matches --padding-top + --padding-bottom exactly.

The ::after is the sizing replica so it has to mirror the real textarea's box, and the textarea has no padding now. Dropping the padding include from ::after and re-recording the auto-grow snapshots should do it.

I'd want to see this one fixed before approving since it hits every auto-grow textarea.

autogrow-extra-height


align-items: start;

width: 100%;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing min-height: inherit here. The old .textarea-wrapper-inner had it, and that's what passed the host's min-height down to .native-wrapper. Without it .native-wrapper inherits auto, so min-height: 200px on the textarea gives you a 200px box with a 45px editable area and 155px you can't type in. Works fine on major-9.0.

The obvious fix overshoots a little though, since .native-wrapper re-inherits the same value and pushes the control out by its own padding, so it may want align-self: stretch instead.

Expected:
min-height-baseline

But when it's not filled it's more like:
min-height-not-filled


:host(.textarea-label-placement-floating) .textarea-control,
:host(.textarea-label-placement-stacked) .textarea-control {
--padding-top: 0px;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This kills --padding-top as a public property for floating and stacked. Before, the zeroing was on :host() so an outer-tree declaration still won. Now it's on an internal element nobody can outrank, so --padding-top: 40px on a floating textarea does nothing, while it still applies on start.

The host sets it to 20px a few lines up only so the ::slotted rule can read it for the slot offset, so I think a separate internal property for that would let --padding-top: 0px go back on :host(). If losing the override is intentional it should be in BREAKING.md.

if (ev.target === ev.currentTarget) {
ev.stopPropagation();
}
ev.stopPropagation();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was there a reason for dropping the target check? It came in with #30384 for #30165.

Since the label now wraps .textarea-start and .textarea-end, clicking slotted content doesn't propagate past ion-textarea any more. Clicking a slotted ion-button still fires its own handler, but a listener on a wrapping ion-item or on document gets nothing, where on major-9.0 both fire once. That takes out click tracking and click-outside handlers over that part of the field.

The item tests don't catch it because onClickCapture intercepts the native textarea path before the label handler runs. Same change is in the input PR.

hidden-render-label-offset

This also happens if you use something like a dynamic font and it lazy loads in after the input renders and the size is different, but in that case it doesn't fix itself ever.

@brandyscarney brandyscarney Aug 7, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Input fix: 9b59417

Textarea fix: 58708ca

Select fix: b5a1d65

*/
return this.renderLabel();

const startSlotWidth = startSlot.getBoundingClientRect().width;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading layout during render() is a bit shaky here. There's no re-measure once the element becomes visible, so a textarea that first renders in a hidden container (inline modal, inactive tab, collapsed accordion) gets 0px and keeps it, and the floating label ends up sitting over the start icon. It fixes itself on first focus, which makes it easy to miss in manual testing.

A ResizeObserver on .textarea-start set up in componentDidLoad would cover this and the stale-measurement case together, and notch-controller.ts already solves the same hidden-element problem for the notch.

Since this method is identical in the input PR, might be worth pulling into utils/forms/ next to createNotchController.


const startSlotWidth = startSlot.getBoundingClientRect().width;
const roundedWidth = Math.round(startSlotWidth * 10) / 10;
const isRTL = document.dir === 'rtl';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using document.dir here misses element-level direction, so a textarea inside a dir="rtl" container on an LTR page gets the sign flipped the wrong way. The RTL screenshots won't catch it since the test harness sets dir on <html>.

The transform mixin auto-flips translate3d but not translate, so switching the outline rule to translate3d(var(--start-slot-adjustment), -32%, 0) would let the mixin handle direction and this sign logic could go away.


:host(.textarea-label-placement-floating),
:host(.textarea-label-placement-stacked) {
--padding-top: 20px;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block is identical in textarea.ios.scss, and it replaced a rule that used to live in the shared textarea.scss. Could it go back there? Same values in both mode files means they can drift.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

package: core @ionic/core package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants