Skip to content

fix(input): support floating labels with slotted content - #31309

Open
brandyscarney wants to merge 28 commits into
major-9.0from
FW-6471-input
Open

fix(input): support floating labels with slotted content#31309
brandyscarney wants to merge 28 commits into
major-9.0from
FW-6471-input

Conversation

@brandyscarney

@brandyscarney brandyscarney commented Jul 30, 2026

Copy link
Copy Markdown
Member

Issue number: resolves #29449 resolves #28665


What is the current behavior?

Inputs with a floating label and a start or end slot always display the label in the floated state, regardless of whether the input 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 input when the field is empty.
    • It floats when the input is focused or contains a value.
  • The height of inputs with start or end slots has been reduced to match the md specification.
  • Start slot content is now always positioned to the left of both the label and the input.
  • The clear button is now vertically centered within the entire input container, rather than only the input element.
  • 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

New wrapper elements have been added to the component's internal DOM structure 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 wrapper elements have been added:

  • Added: <div class="input-start"> wrapper for the start slot
  • Added: <div class="input-control"> wrapper for the label and native control
  • Added: <div class="input-end"> wrapper for the end slot and clear button

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

Other information

Preview

@vercel

vercel Bot commented Jul 30, 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 11, 2026 7:17pm

Request Review

@github-actions github-actions Bot added the package: core @ionic/core package label Jul 30, 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 input-slots to input-slot to match the folder name.


configs().forEach(({ title, screenshot, config }) => {
test.describe(title('input: start and end slots (visual checks)'), () => {
test.describe(title('input: 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.

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 is the correct height and label position for Material Design filled inputs. See: https://m2.material.io/components/text-fields

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 1px shift was caused by the removal of this rule:

/**
* Ensures the input does not
* overlap the label.
*/
:host(.input-label-placement-stacked) input,
:host(.input-label-placement-floating) input {
@include margin(1px, 0, 0, 0);
}

However, this is actually more aligned than before:

Image

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 1px shift was caused by the removal of this rule:

/**
* Ensures the input does not
* overlap the label.
*/
:host(.input-label-placement-stacked) input,
:host(.input-label-placement-floating) input {
@include margin(1px, 0, 0, 0);
}

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 1px shift was caused by the removal of this rule:

/**
* Ensures the input does not
* overlap the label.
*/
:host(.input-label-placement-stacked) input,
:host(.input-label-placement-floating) input {
@include margin(1px, 0, 0, 0);
}

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 1px shift was caused by the removal of this rule:

/**
* Ensures the input does not
* overlap the label.
*/
:host(.input-label-placement-stacked) input,
:host(.input-label-placement-floating) input {
@include margin(1px, 0, 0, 0);
}

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.

The clear input button was updated to be vertically centered based on the entire input container. This aligns with how md treats any trailing icon/button:

Image

@brandyscarney brandyscarney changed the title fix(input): float label when start and end slot exist fix(input): support floating labels with slotted content Jul 31, 2026

@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.

This is looking really great! I had some feedback on a few issues and some minor things, but really looking good!

Comment thread core/src/components/input/input.tsx Outdated
Comment thread core/src/components/input/test/slot/input.e2e.ts
Comment thread core/src/components/input/input.md.outline.scss Outdated
Comment thread core/src/components/input/input.tsx Outdated

@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.

Thanks for the changes! I found a few more issues in this one, some are just test cleanup, most should be easy to implement I hope 🤞

}
);

this.startContainerController.calculateStartContainerWidth();

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.

Calling this only from connectedCallback and the slot mutation callback means a runtime fill change never re-measures, which is why calculateNotchWidth runs from componentDidRender.

With a [fill] binding resolving to outline after the component settles, the adjustment stays at 0px and the label sits 35px right of the notch, on the border instead of inside the cut-out. A static fill="outline" gets -32px. The ResizeObserver never attaches either, so widening the start slot afterwards drifts it 115px off. A runtime dir flip goes stale the same way.

Calling this from componentDidRender covers both.

@@ -100,16 +101,6 @@
max-width: calc((100% - var(--padding-start) - var(--padding-end) - #{$input-md-floating-label-padding * 2}) / #{$form-control-label-stacked-scale});

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 percentage now resolves against .input-control, but the formula still subtracts --padding-start and --padding-end, which .input-control already excludes. So the padding comes off twice.

With no slots the available label width drops from 427px to 384px, which is the 32px of padding over the 0.75 scale. With start and end slots it's 187px. Long labels truncate earlier than intended and no screenshot covers that.

* input field) to prevent double-click events. Allows clicks on slotted
* content to propagate so event delegation works for parent handlers.
*/
private onLabelClick = (ev: MouseEvent) => {

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.

Since .input-control sits inside the label's content box, it never covers the padding. With fill="solid" that's 16px each side, and a click there targets the label itself, so nothing stops it. That strip goes from one click on major-9.0 to two here. Default fill is fine since its padding is 0.

The earlier commit that dropped the ev.target === ev.currentTarget guard is what broke slotted propagation. Excluding slotted content instead covers every case, and also the fill="outline" strip, which is two clicks on both branches:

if (target.closest('[slot="start"], [slot="end"]') === null) {
  ev.stopPropagation();
}

});

test('should propagate clicks from start slot button to parent', async ({ page }) => {
page.setContent(

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.

Suggested change
page.setContent(
await page.setContent(

Missing an await here, and on the end slot test below. Every other setContent in this file awaits it, and it's what waits for hydration.

return computedStyle.getPropertyValue('--internal-start-container-adjustment');
});

expect(adjustment).toMatch(/-?\d+\.?\d*px/);

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 stylesheet declares this property as 0px, which satisfies that regex, so this passes whether or not the measurement ran. The runtime fill case computes 0px and would still pass here. Asserting the value tracks the measured start width would catch it.

Comment thread BREAKING.md

**Internal DOM Structure Changes**

New wrapper elements have been added to the component's internal DOM structure 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.

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 covers the three added wrappers but not what moved out of .native-wrapper, which is the part anyone with custom CSS needs. The start slot, end slot and clear button all left it, and the label is no longer a direct child of the wrapper, so a selector like .native-wrapper [slot="start"] stops matching.

The behavior change is missing too. Floating labels no longer auto-float with a start or end slot present, which also hides the placeholder at rest.

The same text is in the v9 migration guide PR, so both would want the update.

* The solid and outline fills are only supported by `md` mode.
*/
configs({ modes: ['md'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('input: slot'), () => {

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.

Both describe blocks have the same title now. Textarea and select split theirs into visual checks and functionality checks, and this file did too before.

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