diff --git a/BREAKING.md b/BREAKING.md
index 8715719907a..9e19dcf2af1 100644
--- a/BREAKING.md
+++ b/BREAKING.md
@@ -82,6 +82,8 @@ Apps on `moduleResolution: "node"` (classic) and webpack 4 keep resolving throug
Input
+**`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:
@@ -89,6 +91,17 @@ The string form no longer behaves the same way. Because an HTML attribute coerce
- 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: `
` wrapper for the start slot
+- Added: `
` wrapper for the label and native control
+- Added: `
` 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.
+
Legacy Picker
- `ion-picker-legacy` and `ion-picker-legacy-column` have been removed. The legacy picker component has been replaced with an inline picker component.
diff --git a/core/src/components/input/input.md.outline.scss b/core/src/components/input/input.md.outline.scss
index be54121d0e0..fd8028f4959 100644
--- a/core/src/components/input/input.md.outline.scss
+++ b/core/src/components/input/input.md.outline.scss
@@ -8,6 +8,7 @@
--border-radius: 4px;
--padding-start: 16px;
--padding-end: 16px;
+ --start-slot-adjustment: 0px;
min-height: 56px;
}
@@ -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);
/**
@@ -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
// ----------------------------------------------------------------
diff --git a/core/src/components/input/input.scss b/core/src/components/input/input.scss
index 476cb012bee..af1c8231814 100644
--- a/core/src/components/input/input.scss
+++ b/core/src/components/input/input.scss
@@ -189,7 +189,6 @@
// --------------------------------------------------
.input-clear-icon {
- @include margin(auto);
@include padding(0);
@include background-position(center);
@@ -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
// ----------------------------------------------------------------
@@ -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
// ----------------------------------------------------------------
@@ -419,6 +443,15 @@
pointer-events: none;
}
+/**
+ * When start/end slots are added or removed, we temporarily
+ * disable the label transition to prevent the floating label
+ * from animating as its position is adjusted.
+ */
+:host(.skip-label-transition) .label-text-wrapper {
+ transition: none;
+}
+
/**
* We need to use two elements instead of
* one. The .label-text-wrapper is responsible
@@ -467,7 +500,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;
}
@@ -487,7 +520,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;
}
@@ -532,10 +565,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;
}
@@ -559,15 +591,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.
diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx
index 89efdce43dd..93ef4eefe8d 100644
--- a/core/src/components/input/input.tsx
+++ b/core/src/components/input/input.tsx
@@ -85,6 +85,13 @@ export class Input implements ComponentInterface {
*/
@State() isInvalid = false;
+ /**
+ * Temporarily disables the floating label transition while
+ * start/end slots are added or removed. This prevents the
+ * label from animating as its position is adjusted.
+ */
+ @State() skipLabelTransition = false;
+
@Element() el!: HTMLIonInputElement;
private validationObserver?: MutationObserver;
@@ -410,6 +417,17 @@ export class Input implements ComponentInterface {
this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => {
this.setSlottedLabelId();
forceUpdate(this);
+
+ /**
+ * Temporarily disable the label transition until the
+ * next frame so any slot updates don't cause the
+ * label to animate.
+ */
+ this.skipLabelTransition = true;
+
+ requestAnimationFrame(() => {
+ this.skipLabelTransition = false;
+ });
});
this.setSlottedLabelId();
@@ -466,6 +484,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(
@@ -800,57 +838,32 @@ 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 [
-
-
-
-
(this.notchSpacerEl = el)}>
- {this.label}
-
-
-
-
,
- this.renderLabel(),
- ];
- }
-
- /**
- * If not using the outline style,
- * we can render just the label.
- */
- return this.renderLabel();
+ private renderOutlineDecorations() {
+ return [
+ ,
+
+
(this.notchSpacerEl = el)}>
+ {this.label}
+
+
,
+ ,
+ ];
}
render() {
- const { disabled, fill, readonly, shape, inputId, labelPlacement, el, hasFocus, clearInputIcon } = this;
+ const { disabled, fill, readonly, shape, inputId, labelPlacement, hasFocus, clearInputIcon, skipLabelTransition } =
+ this;
const mode = getIonMode(this);
const value = this.getValue();
const inItem = hostContext('ion-item', this.el);
@@ -859,27 +872,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 (
{/**
* htmlFor is needed so that clicking the label always focuses
@@ -903,46 +905,53 @@ export class Input implements ComponentInterface {
* since it comes before the input in the DOM.
*/}