Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
71a8fc3
fix(input): float label when start and end slot exist
brandyscarney Jul 30, 2026
5e98726
test(input): update slot test
brandyscarney Jul 30, 2026
7adbc3e
fix(input): always stop label click propagation
brandyscarney Jul 30, 2026
7dddcf7
chore(): add updated snapshots
Ionitron Jul 30, 2026
26c8c13
fix(input): reverse the row on input-content for end labels
brandyscarney Jul 30, 2026
be85bad
chore(): add updated snapshots
Ionitron Jul 30, 2026
375995b
chore(): add updated snapshots
brandyscarney Jul 30, 2026
7c4e480
fix(input): account for RTL and vertically center the clear input
brandyscarney Jul 30, 2026
f8e5be6
test(input): rename tests and add fill and rtl coverage
brandyscarney Jul 30, 2026
106dfea
chore(): add updated snapshots
brandyscarney Jul 30, 2026
9ef4d4b
test(input): capture slot behavior when value is not present
brandyscarney Jul 30, 2026
eaeada8
chore(): add updated snapshots
brandyscarney Jul 30, 2026
996134f
style: lint
brandyscarney Jul 30, 2026
fd0557a
chore(): add updated snapshots
Ionitron Jul 31, 2026
3072291
fix(input): style the right element for flex
brandyscarney Jul 31, 2026
1483023
fix(input): revert icon change - we need to do it in other components…
brandyscarney Aug 3, 2026
cc20bbf
chore(): add updated snapshots
Ionitron Aug 3, 2026
4ac30a6
style(input): remove extra label function
brandyscarney Aug 3, 2026
892f6e7
docs(breaking): add a section for input structural changes
brandyscarney Aug 5, 2026
921b54e
copy editing
brandyscarney Aug 5, 2026
6c81728
Merge branch 'major-9.0' into FW-6471-input
brandyscarney Aug 5, 2026
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
13 changes: 13 additions & 0 deletions BREAKING.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,26 @@ Apps on `moduleResolution: "node"` (classic) and webpack 4 keep resolving throug

<h4 id="version-9x-input">Input</h4>

**`autocorrect` Property Type Changed to Boolean**

The `autocorrect` property on `ion-input` is now a `boolean` and defaults to `false`. It was previously typed as `'on' | 'off'` with a default of `'off'`. This resolves a type conflict introduced when TypeScript 5.9 added `autocorrect: boolean` to the DOM `HTMLElement` interface.

The string form no longer behaves the same way. Because an HTML attribute coerces to `true` for any non-empty string, `autocorrect="off"` now evaluates to `true` (autocorrect enabled). Migrate to the boolean property:

- Remove the attribute to keep autocorrect disabled (the default).
- Use a property binding to enable it: `[autocorrect]="true"` (Angular), `autocorrect={true}` (React), or `:autocorrect="true"` (Vue).

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

<h4 id="version-9x-legacy-picker">Legacy Picker</h4>

- `ion-picker-legacy` and `ion-picker-legacy-column` have been removed. The legacy picker component has been replaced with an inline picker component.
Expand Down
13 changes: 2 additions & 11 deletions core/src/components/input/input.md.outline.scss
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
--border-radius: 4px;
--padding-start: 16px;
--padding-end: 16px;
--start-slot-adjustment: 0px;

min-height: 56px;
}
Expand Down Expand Up @@ -90,7 +91,7 @@
* This makes the label sit above the input.
*/
:host(.label-floating.input-fill-outline) .label-text-wrapper {
@include transform(translateY(-32%), scale(#{$form-control-label-stacked-scale}));
@include transform(translate(var(--start-slot-adjustment), -32%), scale(#{$form-control-label-stacked-scale}));
@include margin(0);

/**
Expand All @@ -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});
}

/**
* This ensures that the input does not
* overlap the floating label while still
* remaining visually centered.
*/
:host(.input-fill-outline.input-label-placement-stacked) input,
:host(.input-fill-outline.input-label-placement-floating) input {
@include margin(6px, 0, 6px, 0);
}

// Input Fill: Outline Outline Container
// ----------------------------------------------------------------

Expand Down
50 changes: 32 additions & 18 deletions core/src/components/input/input.scss
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,6 @@
// --------------------------------------------------

.input-clear-icon {
@include margin(auto);
@include padding(0);
@include background-position(center);

Expand Down Expand Up @@ -259,6 +258,19 @@
line-height: normal;
}

// Input Control (Label + Input)
// ----------------------------------------------------------------

.input-control {
display: flex;

position: relative;

flex-grow: 1;

width: 100%;
}

// Input Native Wrapper
// ----------------------------------------------------------------

Expand All @@ -269,12 +281,24 @@

flex-grow: 1;

// ensure start/end slot content is vertically centered
align-items: center;

width: 100%;
}

// Input Start/End Containers
// ----------------------------------------------------------------

.input-start,
.input-end {
display: flex;

position: relative;

flex-shrink: 0;

// Ensure start/end content is vertically centered
align-items: center;
}

// Input Highlight
// ----------------------------------------------------------------

Expand Down Expand Up @@ -467,7 +491,7 @@
* Label is on the left of the input in LTR and
* on the right in RTL.
*/
:host(.input-label-placement-start) .input-wrapper {
:host(.input-label-placement-start) .input-control {
flex-direction: row;
}

Expand All @@ -487,7 +511,7 @@
* Label is on the right of the input in LTR and
* on the left in RTL.
*/
:host(.input-label-placement-end) .input-wrapper {
:host(.input-label-placement-end) .input-control {
flex-direction: row-reverse;
}

Expand Down Expand Up @@ -532,10 +556,9 @@
* Floating: Label sits over the input when the input has no
* value and is blurred. Label sits above the input and is scaled
* down when the input is focused or has a value.
*
*/
:host(.input-label-placement-stacked) .input-wrapper,
:host(.input-label-placement-floating) .input-wrapper {
:host(.input-label-placement-stacked) .input-control,
:host(.input-label-placement-floating) .input-control {
flex-direction: column;
align-items: start;
}
Expand All @@ -559,15 +582,6 @@
z-index: 2;
}

/**
* 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);
}

/**
* This makes the label sit over the input
* when the input is blurred and has no value.
Expand Down
187 changes: 88 additions & 99 deletions core/src/components/input/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,26 @@ export class Input implements ComponentInterface {
this.notchController?.calculateNotchWidth();
}

/**
* Gets the width of the start slot, rounded to 1 decimal place.
* Only applies to inputs with `md` mode and `fill="outline"`.
* In RTL mode, the adjustment is positive; in LTR mode, it's negative.
*/
private getStartSlotAdjustment(): string {
const startSlot = this.el.querySelector('.input-start') as HTMLElement | null;
if (!startSlot || !Build.isBrowser || getIonMode(this) !== 'md' || this.fill !== 'outline') {
return '';
}

// Round the width to a half pixel to ensure the label is
// placed properly when a start slot is added or removed.
const startSlotWidth = startSlot.getBoundingClientRect().width;
const roundedWidth = Math.round(startSlotWidth * 10) / 10;
const isRTL = document.dir === 'rtl';
const sign = isRTL ? '' : '-';
return roundedWidth ? `${sign}${roundedWidth}px` : '0px';
}

disconnectedCallback() {
if (Build.isBrowser) {
document.dispatchEvent(
Expand Down Expand Up @@ -800,57 +820,31 @@ export class Input implements ComponentInterface {
* otherwise, two clicks will be triggered.
*/
private onLabelClick = (ev: MouseEvent) => {
// Only stop propagation if the click was directly on the label
// and not on the input or other child elements
if (ev.target === ev.currentTarget) {
ev.stopPropagation();
}
ev.stopPropagation();
};

/**
* Renders the border container
* when fill="outline".
* Renders the outline border with a notch for the label.
*/
private renderLabelContainer() {
const mode = getIonMode(this);
const hasOutlineFill = mode === 'md' && this.fill === 'outline';

if (hasOutlineFill) {
/**
* The outline fill has a special outline
* that appears around the input and the label.
* Certain stacked and floating label placements cause the
* label to translate up and create a "cut out"
* inside of that border by using the notch-spacer element.
*/
return [
<div class="input-outline-container">
<div class="input-outline-start"></div>
<div
class={{
'input-outline-notch': true,
'input-outline-notch-hidden': !this.hasLabel,
}}
>
<div class="notch-spacer" aria-hidden="true" ref={(el) => (this.notchSpacerEl = el)}>
{this.label}
</div>
</div>
<div class="input-outline-end"></div>
</div>,
this.renderLabel(),
];
}

/**
* If not using the outline style,
* we can render just the label.
*/
return this.renderLabel();
private renderOutlineDecorations() {
return [
<div class="input-outline-start"></div>,
<div
class={{
'input-outline-notch': true,
'input-outline-notch-hidden': !this.hasLabel,
}}
>
<div class="notch-spacer" aria-hidden="true" ref={(el) => (this.notchSpacerEl = el)}>
{this.label}
</div>
</div>,
<div class="input-outline-end"></div>,
];
}

render() {
const { disabled, fill, readonly, shape, inputId, labelPlacement, el, hasFocus, clearInputIcon } = this;
const { disabled, fill, readonly, shape, inputId, labelPlacement, hasFocus, clearInputIcon } = this;
const mode = getIonMode(this);
const value = this.getValue();
const inItem = hostContext('ion-item', this.el);
Expand All @@ -859,27 +853,14 @@ export class Input implements ComponentInterface {
const clearIconData = clearInputIcon ?? defaultClearIcon;

const hasValue = this.hasValue();
const hasStartEndSlots = el.querySelector('[slot="start"], [slot="end"]') !== null;
const hasOutlineFill = mode === 'md' && fill === 'outline';

/**
* If the label is stacked, it should always sit above the input.
* For floating labels, the label should move above the input if
* the input has a value, is focused, or has anything in either
* the start or end slot.
*
* If there is content in the start slot, the label would overlap
* it if not forced to float. This is also applied to the end slot
* because with the default or solid fills, the input is not
* vertically centered in the container, but the label is. This
* causes the slots and label to appear vertically offset from each
* other when the label isn't floating above the input. This doesn't
* apply to the outline fill, but this was not accounted for to keep
* things consistent.
*
* TODO(FW-5592): Remove hasStartEndSlots condition
* the input has a value or is focused.
*/
const labelShouldFloat =
labelPlacement === 'stacked' || (labelPlacement === 'floating' && (hasValue || hasFocus || hasStartEndSlots));
const labelShouldFloat = labelPlacement === 'stacked' || (labelPlacement === 'floating' && (hasValue || hasFocus));

return (
<Host
Expand All @@ -895,6 +876,7 @@ export class Input implements ComponentInterface {
'in-item-color': hostContext('ion-item.ion-color', this.el),
'input-disabled': disabled,
})}
style={{ '--start-slot-adjustment': this.getStartSlotAdjustment() }}
>
{/**
* htmlFor is needed so that clicking the label always focuses
Expand All @@ -903,46 +885,53 @@ export class Input implements ComponentInterface {
* since it comes before the input in the DOM.
*/}
<label class="input-wrapper" htmlFor={inputId} onClick={this.onLabelClick}>
{this.renderLabelContainer()}
<div class="native-wrapper" onClick={this.onLabelClick}>
{hasOutlineFill && <div class="input-outline-container">{this.renderOutlineDecorations()}</div>}
<div class="input-start">
<slot name="start"></slot>
<input
class="native-input"
ref={(input) => (this.nativeInput = input)}
id={inputId}
disabled={disabled}
autoCapitalize={this.autocapitalize}
autoComplete={this.autocomplete}
autoCorrect={this.autocorrect ? 'on' : 'off'}
autoFocus={this.autofocus}
enterKeyHint={this.enterkeyhint}
inputMode={this.inputmode}
min={this.min}
max={this.max}
minLength={this.minlength}
maxLength={this.maxlength}
multiple={this.multiple}
name={this.name}
pattern={this.pattern}
placeholder={this.placeholder || ''}
readOnly={readonly}
required={this.required}
spellcheck={this.spellcheck}
step={this.step}
type={this.type}
value={value}
onInput={this.onInput}
onChange={this.onChange}
onBlur={this.onBlur}
onFocus={this.onFocus}
onKeyDown={this.onKeydown}
onCompositionstart={this.onCompositionStart}
onCompositionend={this.onCompositionEnd}
aria-describedby={this.getHintTextID()}
aria-invalid={this.isInvalid ? 'true' : undefined}
aria-labelledby={this.getLabelledById()}
{...this.inheritedAttributes}
/>
</div>
<div class="input-control">
{this.renderLabel()}
<div class="native-wrapper" onClick={this.onLabelClick}>
<input
class="native-input"
ref={(input) => (this.nativeInput = input)}
id={inputId}
disabled={disabled}
autoCapitalize={this.autocapitalize}
autoComplete={this.autocomplete}
autoCorrect={this.autocorrect ? 'on' : 'off'}
autoFocus={this.autofocus}
enterKeyHint={this.enterkeyhint}
inputMode={this.inputmode}
min={this.min}
max={this.max}
minLength={this.minlength}
maxLength={this.maxlength}
multiple={this.multiple}
name={this.name}
pattern={this.pattern}
placeholder={this.placeholder || ''}
readOnly={readonly}
required={this.required}
spellcheck={this.spellcheck}
step={this.step}
type={this.type}
value={value}
onInput={this.onInput}
onChange={this.onChange}
onBlur={this.onBlur}
onFocus={this.onFocus}
onKeyDown={this.onKeydown}
onCompositionstart={this.onCompositionStart}
onCompositionend={this.onCompositionEnd}
aria-describedby={this.getHintTextID()}
aria-invalid={this.isInvalid ? 'true' : undefined}
aria-labelledby={this.getLabelledById()}
{...this.inheritedAttributes}
/>
</div>
</div>
<div class="input-end">
{this.clearInput && !readonly && !disabled && (
<button
aria-label="reset"
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.

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.

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

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.

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);
}

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.

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);
}

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
Loading