From cbff99332a4f1cd2faff7b00f421c53d8c05ee3a Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:47:44 -0400 Subject: [PATCH 1/2] fix(input, select, textarea): prevent floating label from overflowing while it floats --- .../components/input/input.md.outline.scss | 34 ++- core/src/components/input/input.scss | 10 +- core/src/components/input/input.tsx | 24 +- .../components/input/test/slot/input.e2e.ts | 68 ++++++ .../components/select/select.md.outline.scss | 34 ++- core/src/components/select/select.scss | 10 +- core/src/components/select/select.tsx | 24 +- .../components/select/test/slot/select.e2e.ts | 69 ++++++ .../textarea/test/slot/textarea.e2e.ts | 68 ++++++ .../textarea/textarea.md.outline.scss | 36 +-- core/src/components/textarea/textarea.scss | 10 +- core/src/components/textarea/textarea.tsx | 24 +- core/src/themes/ionic.globals.scss | 4 + core/src/utils/forms/index.ts | 2 +- .../utils/forms/start-container-controller.ts | 163 -------------- .../forms/start-end-container-controller.ts | 205 ++++++++++++++++++ ...=> start-end-container-controller.spec.ts} | 135 ++++++++++-- 17 files changed, 652 insertions(+), 268 deletions(-) delete mode 100644 core/src/utils/forms/start-container-controller.ts create mode 100644 core/src/utils/forms/start-end-container-controller.ts rename core/src/utils/forms/test/{start-container-controller.spec.ts => start-end-container-controller.spec.ts} (71%) diff --git a/core/src/components/input/input.md.outline.scss b/core/src/components/input/input.md.outline.scss index d6cda674e58..bbd4360f8e1 100644 --- a/core/src/components/input/input.md.outline.scss +++ b/core/src/components/input/input.md.outline.scss @@ -11,13 +11,22 @@ /** * The measured width of the start container, as a negative offset - * in LTR and a positive one in RTL. Written to the host by the - * start container controller, so renaming it here requires renaming + * in LTR and a positive one in RTL. Written to the host by the start + * and end container controller, so renaming it here requires renaming * it there too. Declared with a default so the label is positioned * correctly before the first measurement runs. */ --internal-start-container-adjustment: 0px; + /** + * The measured combined width of the start and end containers. + * Written to the host by the start and end container controller, so + * renaming it here requires renaming it there too. Declared with a + * default so the floating label falls back to the width of the + * control before the first measurement runs. + */ + --internal-start-end-container-width: 0px; + min-height: 56px; } @@ -74,11 +83,12 @@ } /** - * The resting label is positioned relative to the control so it can size to - * the space between the start and end slots. When floating, the label needs - * to escape the control and position itself relative to the wrapper instead. + * The label is positioned relative to the control whether it is resting + * over the input or floating in the notch, so its max-width always resolves + * against the same width. This lets the max-width animate between the + * resting and floating values without jumping. */ -:host(.input-fill-outline:not(.label-floating)) .input-control { +:host(.input-fill-outline) .input-control { position: relative; } @@ -89,9 +99,8 @@ position: absolute; /** - * While resting this is the width of the text input, which already - * excludes the start and end slots. Once the label floats the - * containing block becomes the wrapper and the rule below takes over. + * This is the width of the text input, which already + * excludes the start and end slots. */ max-width: 100%; } @@ -113,10 +122,11 @@ @include margin(0); /** - * Label text should not extend - * beyond the bounds of the input. + * Label text should not extend beyond the bounds of the input. + * The control excludes the start and end slots, but the notch spans + * the full width of the outline, so their width is added back. */ - max-width: calc((100% - var(--padding-start) - var(--padding-end) - #{$input-md-floating-label-padding * 2}) / #{$form-control-label-stacked-scale}); + max-width: calc((100% + var(--internal-start-end-container-width) - #{$input-md-floating-label-padding * 2}) / #{$form-control-label-stacked-scale}); } // Input Fill: Outline Outline Container diff --git a/core/src/components/input/input.scss b/core/src/components/input/input.scss index f5d02058228..dbf93e7cfe3 100644 --- a/core/src/components/input/input.scss +++ b/core/src/components/input/input.scss @@ -466,7 +466,11 @@ */ max-width: 200px; - transition: color 150ms cubic-bezier(.4, 0, .2, 1), transform 150ms cubic-bezier(.4, 0, .2, 1); + /** + * The max-width animates with the transform so the label does not + * reveal its full floating width while it is still at full size. + */ + transition: color 150ms #{$form-control-label-transition-easing}, transform 150ms #{$form-control-label-transition-easing}, max-width 150ms #{$form-control-label-transition-easing}; /** * This ensures that double tapping this text @@ -481,8 +485,8 @@ * disable the label transition to prevent the floating label * from animating as its position is adjusted. * - * This class is applied by the start container controller, so - * renaming it here requires renaming it there too. + * This class is applied by the start and end container controller, + * so renaming it here requires renaming it there too. */ :host(.skip-label-transition) .label-text-wrapper { transition: none; diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index 2dc919e41b1..9e8103432e6 100644 --- a/core/src/components/input/input.tsx +++ b/core/src/components/input/input.tsx @@ -13,11 +13,11 @@ import { forceUpdate, h, } from '@stencil/core'; -import type { ClickController, NotchController, StartContainerController } from '@utils/forms'; +import type { ClickController, NotchController, StartEndContainerController } from '@utils/forms'; import { createClickController, createNotchController, - createStartContainerController, + createStartEndContainerController, checkInvalidState, } from '@utils/forms'; import type { Attributes } from '@utils/helpers'; @@ -59,8 +59,9 @@ export class Input implements ComponentInterface { private slotMutationController?: SlotMutationController; private notchController?: NotchController; private notchSpacerEl: HTMLElement | undefined; - private startContainerController?: StartContainerController; + private startEndContainerController?: StartEndContainerController; private startContainerEl: HTMLElement | undefined; + private endContainerEl: HTMLElement | undefined; private clickController?: ClickController; private originalIonInput?: EventEmitter; @@ -412,7 +413,7 @@ export class Input implements ComponentInterface { const { el } = this; this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => { - this.startContainerController?.calculateStartContainerWidth(); + this.startEndContainerController?.calculateContainerWidths(); this.setSlottedLabelId(); forceUpdate(this); @@ -425,15 +426,16 @@ export class Input implements ComponentInterface { () => this.labelSlot ); - this.startContainerController = createStartContainerController( + this.startEndContainerController = createStartEndContainerController( el, () => this.startContainerEl, + () => this.endContainerEl, () => { return Build.isBrowser && getIonMode(this) === 'md' && this.fill === 'outline'; } ); - this.startContainerController.calculateStartContainerWidth(); + this.startEndContainerController.calculateContainerWidths(); this.clickController = createClickController(el, () => this.nativeInput); @@ -482,7 +484,7 @@ export class Input implements ComponentInterface { componentDidRender() { this.notchController?.calculateNotchWidth(); - this.startContainerController?.calculateStartContainerWidth(); + this.startEndContainerController?.calculateContainerWidths(); } disconnectedCallback() { @@ -504,9 +506,9 @@ export class Input implements ComponentInterface { this.notchController = undefined; } - if (this.startContainerController) { - this.startContainerController.destroy(); - this.startContainerController = undefined; + if (this.startEndContainerController) { + this.startEndContainerController.destroy(); + this.startEndContainerController = undefined; } // Clean up validation observer to prevent memory leaks @@ -946,7 +948,7 @@ export class Input implements ComponentInterface { /> -
+
(this.endContainerEl = el)}> {this.clearInput && !readonly && !disabled && (
-
+
(this.endContainerEl = el)}> {/** * The icon is rendered in the end container when the * select has a floating or stacked label so it is diff --git a/core/src/components/select/test/slot/select.e2e.ts b/core/src/components/select/test/slot/select.e2e.ts index 3f12c760117..ac7a746a1e7 100644 --- a/core/src/components/select/test/slot/select.e2e.ts +++ b/core/src/components/select/test/slot/select.e2e.ts @@ -513,6 +513,75 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co expect(box!.width).toBeGreaterThanOrEqual(twoCharacterWidth); }); }); + + /** + * The label truncates beside the wide slot and has room for its full + * text once it floats into the notch. It must not reveal that full text + * while it is still at its resting size, or it overflows the select + * partway through the transition. + */ + test('should keep the label inside the select while it floats above a wide end slot', async ({ page }) => { + /** + * The label is longer than the select, so revealing all of it at its + * resting size pushes it past the border, not only over the slot. + */ + await page.setContent( + ` + + ${endSlot} + 100 + + `, + config + ); + + const select = page.locator('ion-select'); + + /** + * Pauses the label's transitions and steps through them so every + * point of the animation is checked regardless of frame timing. + */ + const overflow = await select.evaluate(async (el: HTMLIonSelectElement) => { + const label = el.shadowRoot!.querySelector('.label-text-wrapper')!; + const wrapper = el.shadowRoot!.querySelector('.select-wrapper')!; + const maxRight = wrapper.getBoundingClientRect().right - parseFloat(getComputedStyle(wrapper).paddingRight); + + el.value = '100'; + + /** + * The class that starts the transitions is added on the next render. + * This waits for it here instead of using `page.waitForChanges()`, + * which waits long enough for the 150ms transitions to finish, at + * which point the browser discards them and there is nothing to step + * through. + */ + while (!el.classList.contains('label-floating')) { + await new Promise(requestAnimationFrame); + } + + // Without this a label that already finished floating would pass + const transitions = label.getAnimations(); + if (transitions.length === 0) { + throw new Error('The label did not transition'); + } + transitions.forEach((transition) => transition.pause()); + + // Step through the 150ms label transition in 15ms increments + const overflows = Array.from({ length: 11 }, (_, step) => { + transitions.forEach((transition) => (transition.currentTime = step * 15)); + return label.getBoundingClientRect().right - maxRight; + }); + + return Math.max(...overflows); + }); + + expect(overflow).toBeLessThanOrEqual(1); + }); }); }); diff --git a/core/src/components/textarea/test/slot/textarea.e2e.ts b/core/src/components/textarea/test/slot/textarea.e2e.ts index 87c15eca153..32eb1e6869a 100644 --- a/core/src/components/textarea/test/slot/textarea.e2e.ts +++ b/core/src/components/textarea/test/slot/textarea.e2e.ts @@ -343,6 +343,74 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co expect(box!.width).toBeGreaterThanOrEqual(twoCharacterWidth); }); }); + + /** + * The label truncates beside the wide slot and has room for its full + * text once it floats into the notch. It must not reveal that full text + * while it is still at its resting size, or it overflows the textarea + * partway through the transition. + */ + test('should keep the label inside the textarea while it floats above a wide end slot', async ({ page }) => { + /** + * The label is longer than the textarea, so revealing all of it at its + * resting size pushes it past the border, not only over the slot. + */ + await page.setContent( + ` + + ${endSlot} + + `, + config + ); + + const textarea = page.locator('ion-textarea'); + + /** + * Pauses the label's transitions and steps through them so every + * point of the animation is checked regardless of frame timing. + */ + const overflow = await textarea.evaluate(async (el: HTMLIonTextareaElement) => { + const label = el.querySelector('.label-text-wrapper')!; + const wrapper = el.querySelector('.textarea-wrapper')!; + const maxRight = wrapper.getBoundingClientRect().right - parseFloat(getComputedStyle(wrapper).paddingRight); + + el.value = '100'; + + /** + * The class that starts the transitions is added on the next render. + * This waits for it here instead of using `page.waitForChanges()`, + * which waits long enough for the 150ms transitions to finish, at + * which point the browser discards them and there is nothing to step + * through. + */ + while (!el.classList.contains('label-floating')) { + await new Promise(requestAnimationFrame); + } + + // Without this a label that already finished floating would pass + const transitions = label.getAnimations(); + if (transitions.length === 0) { + throw new Error('The label did not transition'); + } + transitions.forEach((transition) => transition.pause()); + + // Step through the 150ms label transition in 15ms increments + const overflows = Array.from({ length: 11 }, (_, step) => { + transitions.forEach((transition) => (transition.currentTime = step * 15)); + return label.getBoundingClientRect().right - maxRight; + }); + + return Math.max(...overflows); + }); + + expect(overflow).toBeLessThanOrEqual(1); + }); }); }); diff --git a/core/src/components/textarea/textarea.md.outline.scss b/core/src/components/textarea/textarea.md.outline.scss index 6452987a2b2..00d79728edd 100644 --- a/core/src/components/textarea/textarea.md.outline.scss +++ b/core/src/components/textarea/textarea.md.outline.scss @@ -11,12 +11,21 @@ /** * The measured width of the start container, as a negative offset - * in LTR and a positive one in RTL. Written to the host by the - * start container controller, so renaming it here requires renaming + * in LTR and a positive one in RTL. Written to the host by the start + * and end container controller, so renaming it here requires renaming * it there too. Declared with a default so the label is positioned * correctly before the first measurement runs. */ --internal-start-container-adjustment: 0px; + + /** + * The measured combined width of the start and end containers. + * Written to the host by the start and end container controller, so + * renaming it here requires renaming it there too. Declared with a + * default so the floating label falls back to the width of the + * control before the first measurement runs. + */ + --internal-start-end-container-width: 0px; } :host(.textarea-fill-outline.textarea-shape-round) { @@ -72,11 +81,12 @@ } /** - * The resting label is positioned relative to the control so it can size to - * the space between the start and end slots. When floating, the label needs - * to escape the control and position itself relative to the wrapper instead. + * The label is positioned relative to the control whether it is resting + * over the textarea or floating in the notch, so its max-width always resolves + * against the same width. This lets the max-width animate between the + * resting and floating values without jumping. */ -:host(.textarea-fill-outline:not(.label-floating)) .textarea-control { +:host(.textarea-fill-outline) .textarea-control { position: relative; } @@ -87,9 +97,8 @@ position: absolute; /** - * While resting this is the width of the textarea, which already - * excludes the start and end slots. Once the label floats the - * containing block becomes the wrapper and the rule below takes over. + * This is the width of the textarea, which already + * excludes the start and end slots. */ max-width: 100%; } @@ -111,12 +120,11 @@ @include margin(0); /** - * Label text should not extend - * beyond the bounds of the textarea. + * Label text should not extend beyond the bounds of the textarea. + * The control excludes the start and end slots, but the notch spans + * the full width of the outline, so their width is added back. */ - max-width: calc( - (100% - var(--padding-start) - var(--padding-end) - #{$textarea-md-floating-label-padding * 2}) / #{$form-control-label-stacked-scale} - ); + max-width: calc((100% + var(--internal-start-end-container-width) - #{$textarea-md-floating-label-padding * 2}) / #{$form-control-label-stacked-scale}); } /** diff --git a/core/src/components/textarea/textarea.scss b/core/src/components/textarea/textarea.scss index 7db3f21df17..84ad244d67f 100644 --- a/core/src/components/textarea/textarea.scss +++ b/core/src/components/textarea/textarea.scss @@ -496,7 +496,11 @@ */ max-width: 200px; - transition: color 150ms cubic-bezier(0.4, 0, 0.2, 1), transform 150ms cubic-bezier(0.4, 0, 0.2, 1); + /** + * The max-width animates with the transform so the label does not + * reveal its full floating width while it is still at full size. + */ + transition: color 150ms #{$form-control-label-transition-easing}, transform 150ms #{$form-control-label-transition-easing}, max-width 150ms #{$form-control-label-transition-easing}; /** * This ensures that double tapping this text @@ -511,8 +515,8 @@ * disable the label transition to prevent the floating label * from animating as its position is adjusted. * - * This class is applied by the start container controller, so - * renaming it here requires renaming it there too. + * This class is applied by the start and end container controller, + * so renaming it here requires renaming it there too. */ :host(.skip-label-transition) .label-text-wrapper { transition: none; diff --git a/core/src/components/textarea/textarea.tsx b/core/src/components/textarea/textarea.tsx index c893a652144..297b5cdebc3 100644 --- a/core/src/components/textarea/textarea.tsx +++ b/core/src/components/textarea/textarea.tsx @@ -14,11 +14,11 @@ import { h, writeTask, } from '@stencil/core'; -import type { ClickController, NotchController, StartContainerController } from '@utils/forms'; +import type { ClickController, NotchController, StartEndContainerController } from '@utils/forms'; import { createClickController, createNotchController, - createStartContainerController, + createStartEndContainerController, checkInvalidState, } from '@utils/forms'; import type { Attributes } from '@utils/helpers'; @@ -68,8 +68,9 @@ export class Textarea implements ComponentInterface { private notchController?: NotchController; private notchSpacerEl: HTMLElement | undefined; - private startContainerController?: StartContainerController; + private startEndContainerController?: StartEndContainerController; private startContainerEl: HTMLElement | undefined; + private endContainerEl: HTMLElement | undefined; private clickController?: ClickController; /** @@ -343,7 +344,7 @@ export class Textarea implements ComponentInterface { const { el } = this; this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => { - this.startContainerController?.calculateStartContainerWidth(); + this.startEndContainerController?.calculateContainerWidths(); forceUpdate(this); }); @@ -354,15 +355,16 @@ export class Textarea implements ComponentInterface { () => this.labelSlot ); - this.startContainerController = createStartContainerController( + this.startEndContainerController = createStartEndContainerController( el, () => this.startContainerEl, + () => this.endContainerEl, () => { return Build.isBrowser && getIonMode(this) === 'md' && this.fill === 'outline'; } ); - this.startContainerController.calculateStartContainerWidth(); + this.startEndContainerController.calculateContainerWidths(); this.clickController = createClickController(el, () => this.nativeInput); @@ -415,9 +417,9 @@ export class Textarea implements ComponentInterface { this.notchController = undefined; } - if (this.startContainerController) { - this.startContainerController.destroy(); - this.startContainerController = undefined; + if (this.startEndContainerController) { + this.startEndContainerController.destroy(); + this.startEndContainerController = undefined; } // Clean up validation observer to prevent memory leaks @@ -441,7 +443,7 @@ export class Textarea implements ComponentInterface { componentDidRender() { this.notchController?.calculateNotchWidth(); - this.startContainerController?.calculateStartContainerWidth(); + this.startEndContainerController?.calculateContainerWidths(); } /** @@ -815,7 +817,7 @@ export class Textarea implements ComponentInterface {
-
+
(this.endContainerEl = el)}>
{shouldRenderHighlight &&
} diff --git a/core/src/themes/ionic.globals.scss b/core/src/themes/ionic.globals.scss index 1d5789607f1..ae1e28cb7e7 100644 --- a/core/src/themes/ionic.globals.scss +++ b/core/src/themes/ionic.globals.scss @@ -53,6 +53,10 @@ $form-control-min-width: 2ch; /// iOS does not have a floating label design spec, so we standardize on 0.75. $form-control-label-stacked-scale: 0.75; +// Easing for the color, transform, and max-width transitions of a form +// control label as it moves between resting and floating. +$form-control-label-transition-easing: cubic-bezier(.4, 0, .2, 1); + // Z-Index // -------------------------------------------------- diff --git a/core/src/utils/forms/index.ts b/core/src/utils/forms/index.ts index 31732efac24..ef17d70a583 100644 --- a/core/src/utils/forms/index.ts +++ b/core/src/utils/forms/index.ts @@ -1,5 +1,5 @@ export * from './notch-controller'; -export * from './start-container-controller'; +export * from './start-end-container-controller'; export * from './compare-with-utils'; export * from './item-multiple-inputs'; export * from './click-controller'; diff --git a/core/src/utils/forms/start-container-controller.ts b/core/src/utils/forms/start-container-controller.ts deleted file mode 100644 index 3038f470162..00000000000 --- a/core/src/utils/forms/start-container-controller.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { raf } from '@utils/helpers'; -import { isRTL } from '@utils/rtl'; - -/** - * Applied to the host while the label is being repositioned. The - * component stylesheets style this class, so it must stay in sync with - * the `skip-label-transition` selector in those files. Renaming it here - * requires renaming it there too. - */ -export const SKIP_LABEL_TRANSITION_CLASS = 'skip-label-transition'; - -/** - * Set on the host as an inline style. The component stylesheets declare - * this property and read it with `var()`, so it must stay in sync with - * the `--internal-start-container-adjustment` references in those files. - * Renaming it here requires renaming it there too. - */ -export const START_CONTAINER_ADJUSTMENT_VAR = '--internal-start-container-adjustment'; - -/** - * A utility to measure and update the start container width adjustment - * for form components (input, textarea, select) with `fill="outline"`. - * This adjustment compensates for the start container width when - * positioning floating/stacked labels within the outline border. - * - * Measurement is deferred with raf() to avoid forcing layout during - * component updates, and a ResizeObserver watches for dynamic width - * changes (e.g., when web fonts load or async content changes). - * - * @internal - * @param el - The host element (ion-input, ion-textarea, or ion-select). - * @param getStartContainer - A callback that returns the start container element. - * @param shouldApplyAdjustment - A callback that determines whether the adjustment should be applied. - */ -export const createStartContainerController = ( - el: HTMLElement, - getStartContainer: () => HTMLElement | undefined, - shouldApplyAdjustment: () => boolean -): StartContainerController => { - let resizeObserver: ResizeObserver | undefined; - let observedContainer: HTMLElement | undefined; - let measurementRaf: number | undefined; - let transitionRaf: number | undefined; - let appliedAdjustment: string | undefined; - - const calculateAdjustment = (): string => { - const startSlot = getStartContainer(); - if (!startSlot || !shouldApplyAdjustment()) { - return ''; - } - - /* - * Round to one decimal place so fractional widths produce a stable - * label offset when the start container is added, removed, or resized. - */ - const startContainerWidth = startSlot.getBoundingClientRect().width; - const roundedWidth = Math.round(startContainerWidth * 10) / 10; - const sign = isRTL(el) ? '' : '-'; - return roundedWidth ? `${sign}${roundedWidth}px` : '0px'; - }; - - const scheduleAdjustmentUpdate = () => { - // Return if there is already a measurement queued to avoid a flicker - if (measurementRaf !== undefined) { - return; - } - - /* - * Defer the measurement until the next animation frame so layout - * is measured after the current update has been applied. - */ - measurementRaf = raf(() => { - measurementRaf = undefined; - - const adjustment = calculateAdjustment(); - - // Only suppress the label transition when the label has to move - if (adjustment !== appliedAdjustment) { - // Discard a removal queued by an earlier change - if (transitionRaf !== undefined) { - cancelAnimationFrame(transitionRaf); - } - - el.classList.add(SKIP_LABEL_TRANSITION_CLASS); - el.style.setProperty(START_CONTAINER_ADJUSTMENT_VAR, adjustment); - appliedAdjustment = adjustment; - - // Re-enable the transition once the new offset has been painted - transitionRaf = raf(() => { - transitionRaf = undefined; - el.classList.remove(SKIP_LABEL_TRANSITION_CLASS); - }); - } - - setupResizeObserver(); - }); - }; - - const disconnectResizeObserver = () => { - if (resizeObserver) { - resizeObserver.disconnect(); - resizeObserver = undefined; - observedContainer = undefined; - } - }; - - const setupResizeObserver = () => { - const startSlot = getStartContainer(); - - // Stop watching once the adjustment no longer applies - if (!startSlot || !shouldApplyAdjustment()) { - disconnectResizeObserver(); - return; - } - - // Return when prerendering or already watching this container - if (typeof ResizeObserver !== 'function' || (resizeObserver && observedContainer === startSlot)) { - return; - } - - disconnectResizeObserver(); - - resizeObserver = new ResizeObserver(() => { - scheduleAdjustmentUpdate(); - }); - - observedContainer = startSlot; - resizeObserver.observe(startSlot); - }; - - const calculateStartContainerWidth = () => { - scheduleAdjustmentUpdate(); - }; - - const destroy = () => { - disconnectResizeObserver(); - - if (measurementRaf !== undefined) { - cancelAnimationFrame(measurementRaf); - measurementRaf = undefined; - } - - // Tearing down mid-toggle would leave the transition disabled - if (transitionRaf !== undefined) { - cancelAnimationFrame(transitionRaf); - transitionRaf = undefined; - el.classList.remove(SKIP_LABEL_TRANSITION_CLASS); - } - - // Re-apply from scratch if the element is connected again - appliedAdjustment = undefined; - }; - - return { - calculateStartContainerWidth, - destroy, - }; -}; - -export type StartContainerController = { - calculateStartContainerWidth: () => void; - destroy: () => void; -}; diff --git a/core/src/utils/forms/start-end-container-controller.ts b/core/src/utils/forms/start-end-container-controller.ts new file mode 100644 index 00000000000..9a297dbf388 --- /dev/null +++ b/core/src/utils/forms/start-end-container-controller.ts @@ -0,0 +1,205 @@ +import { raf } from '@utils/helpers'; +import { isRTL } from '@utils/rtl'; + +/** + * Applied to the host while the label is being repositioned. The + * component stylesheets style this class, so it must stay in sync with + * the `skip-label-transition` selector in those files. Renaming it here + * requires renaming it there too. + */ +export const SKIP_LABEL_TRANSITION_CLASS = 'skip-label-transition'; + +/** + * Set on the host as an inline style. The component stylesheets declare + * this property and read it with `var()`, so it must stay in sync with + * the `--internal-start-container-adjustment` references in those files. + * Renaming it here requires renaming it there too. + */ +export const START_CONTAINER_ADJUSTMENT_VAR = '--internal-start-container-adjustment'; + +/** + * Set on the host as an inline style. The component stylesheets declare + * this property and read it with `var()`, so it must stay in sync with + * the `--internal-start-end-container-width` references in those files. + * Renaming it here requires renaming it there too. + */ +export const START_END_CONTAINER_WIDTH_VAR = '--internal-start-end-container-width'; + +/** + * A utility to measure the start and end containers of form components + * (input, textarea, select) with `fill="outline"` and write the results + * to the host. The start container width offsets floating/stacked labels + * so they line up with the outline border. The combined start and end + * container width lets the floating label size itself to the full width + * of the outline while still being positioned relative to the control. + * + * Measurement is deferred with raf() to avoid forcing layout during + * component updates, and a ResizeObserver watches for dynamic width + * changes (e.g., when web fonts load or async content changes). + * + * @internal + * @param el - The host element (ion-input, ion-textarea, or ion-select). + * @param getStartContainer - A callback that returns the start container element. + * @param getEndContainer - A callback that returns the end container element. + * @param shouldMeasure - A callback that determines whether the containers should be measured. + */ +export const createStartEndContainerController = ( + el: HTMLElement, + getStartContainer: () => HTMLElement | undefined, + getEndContainer: () => HTMLElement | undefined, + shouldMeasure: () => boolean +): StartEndContainerController => { + let resizeObserver: ResizeObserver | undefined; + let observedStartContainer: HTMLElement | undefined; + let observedEndContainer: HTMLElement | undefined; + let measurementRaf: number | undefined; + let transitionRaf: number | undefined; + let appliedAdjustment: string | undefined; + let appliedStartEndWidth: string | undefined; + + /* + * Round to one decimal place so fractional widths produce a stable + * label offset and width when a container is added, removed, or resized. + */ + const roundWidth = (width: number) => Math.round(width * 10) / 10; + + const measureContainers = (): { adjustment: string; startEndWidth: string } => { + const startContainer = getStartContainer(); + if (!startContainer || !shouldMeasure()) { + return { adjustment: '', startEndWidth: '' }; + } + + const startContainerWidth = roundWidth(startContainer.getBoundingClientRect().width); + const endContainerWidth = roundWidth(getEndContainer()?.getBoundingClientRect().width ?? 0); + const sign = isRTL(el) ? '' : '-'; + + return { + adjustment: startContainerWidth ? `${sign}${startContainerWidth}px` : '0px', + startEndWidth: `${roundWidth(startContainerWidth + endContainerWidth)}px`, + }; + }; + + const scheduleMeasurement = () => { + // Return if there is already a measurement queued to avoid a flicker + if (measurementRaf !== undefined) { + return; + } + + /* + * Defer the measurement until the next animation frame so layout + * is measured after the current update has been applied. + */ + measurementRaf = raf(() => { + measurementRaf = undefined; + + const { adjustment, startEndWidth } = measureContainers(); + + /* + * The width only changes how much of the label fits, so it can + * animate. The transition is not suppressed here because the end + * container can change size while the label is floating, such as + * when async content loads, and that should not cut the float short. + */ + if (startEndWidth !== appliedStartEndWidth) { + el.style.setProperty(START_END_CONTAINER_WIDTH_VAR, startEndWidth); + appliedStartEndWidth = startEndWidth; + } + + // Only suppress the label transition when the label has to move + if (adjustment !== appliedAdjustment) { + // Discard a removal queued by an earlier change + if (transitionRaf !== undefined) { + cancelAnimationFrame(transitionRaf); + } + + el.classList.add(SKIP_LABEL_TRANSITION_CLASS); + el.style.setProperty(START_CONTAINER_ADJUSTMENT_VAR, adjustment); + appliedAdjustment = adjustment; + + // Re-enable the transition once the new offset has been painted + transitionRaf = raf(() => { + transitionRaf = undefined; + el.classList.remove(SKIP_LABEL_TRANSITION_CLASS); + }); + } + + setupResizeObserver(); + }); + }; + + const disconnectResizeObserver = () => { + if (resizeObserver) { + resizeObserver.disconnect(); + resizeObserver = undefined; + observedStartContainer = undefined; + observedEndContainer = undefined; + } + }; + + const setupResizeObserver = () => { + const startContainer = getStartContainer(); + const endContainer = getEndContainer(); + + // Stop watching once the measurements no longer apply + if (!startContainer || !shouldMeasure()) { + disconnectResizeObserver(); + return; + } + + // Return when prerendering or already watching these containers + if ( + typeof ResizeObserver !== 'function' || + (resizeObserver && observedStartContainer === startContainer && observedEndContainer === endContainer) + ) { + return; + } + + disconnectResizeObserver(); + + resizeObserver = new ResizeObserver(() => { + scheduleMeasurement(); + }); + + observedStartContainer = startContainer; + resizeObserver.observe(startContainer); + + if (endContainer) { + observedEndContainer = endContainer; + resizeObserver.observe(endContainer); + } + }; + + const calculateContainerWidths = () => { + scheduleMeasurement(); + }; + + const destroy = () => { + disconnectResizeObserver(); + + if (measurementRaf !== undefined) { + cancelAnimationFrame(measurementRaf); + measurementRaf = undefined; + } + + // Tearing down mid-toggle would leave the transition disabled + if (transitionRaf !== undefined) { + cancelAnimationFrame(transitionRaf); + transitionRaf = undefined; + el.classList.remove(SKIP_LABEL_TRANSITION_CLASS); + } + + // Re-apply from scratch if the element is connected again + appliedAdjustment = undefined; + appliedStartEndWidth = undefined; + }; + + return { + calculateContainerWidths, + destroy, + }; +}; + +export type StartEndContainerController = { + calculateContainerWidths: () => void; + destroy: () => void; +}; diff --git a/core/src/utils/forms/test/start-container-controller.spec.ts b/core/src/utils/forms/test/start-end-container-controller.spec.ts similarity index 71% rename from core/src/utils/forms/test/start-container-controller.spec.ts rename to core/src/utils/forms/test/start-end-container-controller.spec.ts index e5c474356ba..cbe34c685b9 100644 --- a/core/src/utils/forms/test/start-container-controller.spec.ts +++ b/core/src/utils/forms/test/start-end-container-controller.spec.ts @@ -1,9 +1,10 @@ -import type { StartContainerController } from '../start-container-controller'; +import type { StartEndContainerController } from '../start-end-container-controller'; import { SKIP_LABEL_TRANSITION_CLASS, START_CONTAINER_ADJUSTMENT_VAR, - createStartContainerController, -} from '../start-container-controller'; + START_END_CONTAINER_WIDTH_VAR, + createStartEndContainerController, +} from '../start-end-container-controller'; /** * The controller defers every measurement to an animation frame, so the tests @@ -91,18 +92,25 @@ const restoreGlobals = () => { globalOverrides.clear(); }; -describe('Start Container Controller', () => { +describe('Start and End Container Controller', () => { let host: HTMLElement; let startContainer: HTMLElement; - let controller: StartContainerController; + let endContainer: HTMLElement; + let controller: StartEndContainerController; let shouldApply: boolean; const setStartWidth = (width: number) => { startContainer.getBoundingClientRect = () => ({ width } as DOMRect); }; + const setEndWidth = (width: number) => { + endContainer.getBoundingClientRect = () => ({ width } as DOMRect); + }; + const getAdjustment = () => host.style.getPropertyValue(START_CONTAINER_ADJUSTMENT_VAR); + const getStartEndWidth = () => host.style.getPropertyValue(START_END_CONTAINER_WIDTH_VAR); + const hasSkipClass = () => host.classList.contains(SKIP_LABEL_TRANSITION_CLASS); /** @@ -110,7 +118,7 @@ describe('Start Container Controller', () => { * measurement frame and the follow-up transition frame run. */ const measure = () => { - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); flushFrames(); flushFrames(); }; @@ -127,14 +135,18 @@ describe('Start Container Controller', () => { host = document.createElement('div'); startContainer = document.createElement('div'); + endContainer = document.createElement('div'); host.appendChild(startContainer); + host.appendChild(endContainer); document.body.appendChild(host); setStartWidth(32); + setEndWidth(0); - controller = createStartContainerController( + controller = createStartEndContainerController( host, () => startContainer, + () => endContainer, () => shouldApply ); }); @@ -161,7 +173,7 @@ describe('Start Container Controller', () => { }); it('should not measure until the next frame', () => { - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); expect(getAdjustment()).toBe(''); }); @@ -173,8 +185,8 @@ describe('Start Container Controller', () => { * the offset into the next frame, leaving one painted frame where the * content has changed but the label has not moved. */ - requestAnimationFrame(() => controller.calculateStartContainerWidth()); - controller.calculateStartContainerWidth(); + requestAnimationFrame(() => controller.calculateContainerWidths()); + controller.calculateContainerWidths(); flushFrames(); @@ -206,11 +218,47 @@ describe('Start Container Controller', () => { expect(getAdjustment()).toBe('32px'); }); + + it('should set the width to the combined start and end container widths', () => { + setEndWidth(48); + + measure(); + + expect(getStartEndWidth()).toBe('80px'); + }); + + it('should not negate the start and end width in rtl', () => { + host.dir = 'rtl'; + setEndWidth(48); + + measure(); + + expect(getStartEndWidth()).toBe('80px'); + }); + + it('should round the combined start and end width to one decimal place', () => { + setStartWidth(32.06); + setEndWidth(48.02); + + measure(); + + expect(getStartEndWidth()).toBe('80.1px'); + }); + + it('should clear the start and end width when it no longer applies', () => { + measure(); + expect(getStartEndWidth()).toBe('32px'); + + shouldApply = false; + measure(); + + expect(getStartEndWidth()).toBe(''); + }); }); describe('label transition', () => { it('should suppress the transition for one frame when the offset changes', () => { - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); flushFrames(); expect(hasSkipClass()).toBe(true); @@ -222,14 +270,29 @@ describe('Start Container Controller', () => { it('should not suppress the transition when the offset is unchanged', () => { measure(); - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); flushFrames(); expect(hasSkipClass()).toBe(false); }); + it('should not suppress the transition when only the end container changes', () => { + /** + * The end container can resize while the label is floating. Cutting + * the transition short would snap the label into place. + */ + measure(); + + setEndWidth(48); + controller.calculateContainerWidths(); + flushFrames(); + + expect(getStartEndWidth()).toBe('80px'); + expect(hasSkipClass()).toBe(false); + }); + it('should keep the transition suppressed while consecutive changes are applied', () => { - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); flushFrames(); /** @@ -237,7 +300,7 @@ describe('Start Container Controller', () => { * frame that applies the second one. */ setStartWidth(64); - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); flushFrames(); expect(getAdjustment()).toBe('-64px'); @@ -246,18 +309,18 @@ describe('Start Container Controller', () => { }); describe('resize observer', () => { - it('should observe the start container after measuring', () => { + it('should observe the start and end containers after measuring', () => { measure(); - expect(activeObserver()?.observed).toEqual([startContainer]); + expect(activeObserver()?.observed).toEqual([startContainer, endContainer]); }); - it('should not rebind the observer when the container is unchanged', () => { + it('should not rebind the observer when the containers are unchanged', () => { measure(); measure(); expect(MockResizeObserver.instances).toHaveLength(1); - expect(activeObserver()?.observed).toEqual([startContainer]); + expect(activeObserver()?.observed).toEqual([startContainer, endContainer]); }); it('should rebind when the start container is replaced', () => { @@ -269,7 +332,20 @@ describe('Start Container Controller', () => { measure(); expect(firstObserver?.disconnected).toBe(true); - expect(activeObserver()?.observed).toEqual([startContainer]); + expect(activeObserver()?.observed).toEqual([startContainer, endContainer]); + }); + + it('should rebind when the end container is replaced', () => { + measure(); + const firstObserver = activeObserver(); + + endContainer = document.createElement('div'); + setEndWidth(48); + measure(); + + expect(firstObserver?.disconnected).toBe(true); + expect(activeObserver()?.observed).toEqual([startContainer, endContainer]); + expect(getStartEndWidth()).toBe('80px'); }); it('should re-measure when the container resizes', () => { @@ -282,6 +358,16 @@ describe('Start Container Controller', () => { expect(getAdjustment()).toBe('-64px'); }); + it('should re-measure when the end container resizes', () => { + measure(); + + setEndWidth(48); + activeObserver()?.callback(); + flushFrames(); + + expect(getStartEndWidth()).toBe('80px'); + }); + it('should disconnect when the adjustment stops applying', () => { measure(); const observer = activeObserver(); @@ -302,7 +388,7 @@ describe('Start Container Controller', () => { measure(); expect(getAdjustment()).toBe('-32px'); - expect(activeObserver()?.observed).toEqual([startContainer]); + expect(activeObserver()?.observed).toEqual([startContainer, endContainer]); }); }); @@ -317,7 +403,7 @@ describe('Start Container Controller', () => { }); it('should not measure after a pending frame is cancelled', () => { - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); controller.destroy(); flushFrames(); @@ -326,7 +412,7 @@ describe('Start Container Controller', () => { }); it('should not leave the transition suppressed when torn down mid-toggle', () => { - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); // The class is applied, but the frame that removes it has not run yet flushFrames(); @@ -341,15 +427,16 @@ describe('Start Container Controller', () => { measure(); controller.destroy(); - controller = createStartContainerController( + controller = createStartEndContainerController( host, () => startContainer, + () => endContainer, () => shouldApply ); measure(); expect(getAdjustment()).toBe('-32px'); - expect(activeObserver()?.observed).toEqual([startContainer]); + expect(activeObserver()?.observed).toEqual([startContainer, endContainer]); }); }); }); From f7c06a37b6a7807b21b5d683f779de1b57e6e086 Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:09:23 -0400 Subject: [PATCH 2/2] test(input, select, textarea): fix flakiness --- .../components/input/test/slot/input.e2e.ts | 33 ++++++++++++++----- .../components/select/test/slot/select.e2e.ts | 33 ++++++++++++++----- .../textarea/test/slot/textarea.e2e.ts | 33 ++++++++++++++----- 3 files changed, 72 insertions(+), 27 deletions(-) diff --git a/core/src/components/input/test/slot/input.e2e.ts b/core/src/components/input/test/slot/input.e2e.ts index 68b00c8d7fe..e0b8eea9359 100644 --- a/core/src/components/input/test/slot/input.e2e.ts +++ b/core/src/components/input/test/slot/input.e2e.ts @@ -369,6 +369,14 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co const input = page.locator('ion-input'); + /** + * The first measurement of the start container disables the label + * transition for a frame. Wait for it to finish so the label is not + * floated while its transitions are turned off. + */ + await expect(input).toHaveAttribute('style', /--internal-start-container-adjustment/); + await expect(input).not.toHaveClass(/skip-label-transition/); + /** * Pauses the label's transitions and steps through them so every * point of the animation is checked regardless of frame timing. @@ -378,18 +386,25 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co const wrapper = el.querySelector('.input-wrapper')!; const maxRight = wrapper.getBoundingClientRect().right - parseFloat(getComputedStyle(wrapper).paddingRight); - el.value = '100'; - /** * The class that starts the transitions is added on the next render. - * This waits for it here instead of using `page.waitForChanges()`, - * which waits long enough for the 150ms transitions to finish, at - * which point the browser discards them and there is nothing to step - * through. + * A mutation observer runs right after that render, before the next + * frame, so the transitions are caught at their start. Waiting for a + * frame or using `page.waitForChanges()` can let the 150ms transitions + * finish on a slow machine, at which point the browser discards them + * and there is nothing to step through. */ - while (!el.classList.contains('label-floating')) { - await new Promise(requestAnimationFrame); - } + await new Promise((resolve) => { + const observer = new MutationObserver(() => { + if (el.classList.contains('label-floating')) { + observer.disconnect(); + resolve(); + } + }); + observer.observe(el, { attributeFilter: ['class'] }); + + el.value = '100'; + }); // Without this a label that already finished floating would pass const transitions = label.getAnimations(); diff --git a/core/src/components/select/test/slot/select.e2e.ts b/core/src/components/select/test/slot/select.e2e.ts index ac7a746a1e7..dae11c5a79c 100644 --- a/core/src/components/select/test/slot/select.e2e.ts +++ b/core/src/components/select/test/slot/select.e2e.ts @@ -542,6 +542,14 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co const select = page.locator('ion-select'); + /** + * The first measurement of the start container disables the label + * transition for a frame. Wait for it to finish so the label is not + * floated while its transitions are turned off. + */ + await expect(select).toHaveAttribute('style', /--internal-start-container-adjustment/); + await expect(select).not.toHaveClass(/skip-label-transition/); + /** * Pauses the label's transitions and steps through them so every * point of the animation is checked regardless of frame timing. @@ -551,18 +559,25 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co const wrapper = el.shadowRoot!.querySelector('.select-wrapper')!; const maxRight = wrapper.getBoundingClientRect().right - parseFloat(getComputedStyle(wrapper).paddingRight); - el.value = '100'; - /** * The class that starts the transitions is added on the next render. - * This waits for it here instead of using `page.waitForChanges()`, - * which waits long enough for the 150ms transitions to finish, at - * which point the browser discards them and there is nothing to step - * through. + * A mutation observer runs right after that render, before the next + * frame, so the transitions are caught at their start. Waiting for a + * frame or using `page.waitForChanges()` can let the 150ms transitions + * finish on a slow machine, at which point the browser discards them + * and there is nothing to step through. */ - while (!el.classList.contains('label-floating')) { - await new Promise(requestAnimationFrame); - } + await new Promise((resolve) => { + const observer = new MutationObserver(() => { + if (el.classList.contains('label-floating')) { + observer.disconnect(); + resolve(); + } + }); + observer.observe(el, { attributeFilter: ['class'] }); + + el.value = '100'; + }); // Without this a label that already finished floating would pass const transitions = label.getAnimations(); diff --git a/core/src/components/textarea/test/slot/textarea.e2e.ts b/core/src/components/textarea/test/slot/textarea.e2e.ts index 32eb1e6869a..14e30727d1b 100644 --- a/core/src/components/textarea/test/slot/textarea.e2e.ts +++ b/core/src/components/textarea/test/slot/textarea.e2e.ts @@ -371,6 +371,14 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co const textarea = page.locator('ion-textarea'); + /** + * The first measurement of the start container disables the label + * transition for a frame. Wait for it to finish so the label is not + * floated while its transitions are turned off. + */ + await expect(textarea).toHaveAttribute('style', /--internal-start-container-adjustment/); + await expect(textarea).not.toHaveClass(/skip-label-transition/); + /** * Pauses the label's transitions and steps through them so every * point of the animation is checked regardless of frame timing. @@ -380,18 +388,25 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co const wrapper = el.querySelector('.textarea-wrapper')!; const maxRight = wrapper.getBoundingClientRect().right - parseFloat(getComputedStyle(wrapper).paddingRight); - el.value = '100'; - /** * The class that starts the transitions is added on the next render. - * This waits for it here instead of using `page.waitForChanges()`, - * which waits long enough for the 150ms transitions to finish, at - * which point the browser discards them and there is nothing to step - * through. + * A mutation observer runs right after that render, before the next + * frame, so the transitions are caught at their start. Waiting for a + * frame or using `page.waitForChanges()` can let the 150ms transitions + * finish on a slow machine, at which point the browser discards them + * and there is nothing to step through. */ - while (!el.classList.contains('label-floating')) { - await new Promise(requestAnimationFrame); - } + await new Promise((resolve) => { + const observer = new MutationObserver(() => { + if (el.classList.contains('label-floating')) { + observer.disconnect(); + resolve(); + } + }); + observer.observe(el, { attributeFilter: ['class'] }); + + el.value = '100'; + }); // Without this a label that already finished floating would pass const transitions = label.getAnimations();