diff --git a/core/src/components/input/input.md.outline.scss b/core/src/components/input/input.md.outline.scss index 30367cf8077..4efbd2fcdd1 100644 --- a/core/src/components/input/input.md.outline.scss +++ b/core/src/components/input/input.md.outline.scss @@ -172,6 +172,16 @@ opacity: 0; pointer-events: none; + + /** + * The spacer currently inherits + * border-box sizing from the Ionic reset styles. + * However, we do not want to include padding in + * the calculation of the element dimensions. + * This code can be removed if input is updated + * to use the Shadow DOM. + */ + box-sizing: content-box; } :host(.input-fill-outline) .input-outline-start { diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index 7ec546ed9eb..b1630ff0bc4 100644 --- a/core/src/components/input/input.tsx +++ b/core/src/components/input/input.tsx @@ -1,7 +1,7 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; import { Build, Component, Element, Event, Host, Method, Prop, State, Watch, h } from '@stencil/core'; -import type { LegacyFormController } from '@utils/forms'; -import { createLegacyFormController } from '@utils/forms'; +import type { LegacyFormController, NotchController } from '@utils/forms'; +import { createLegacyFormController, createNotchController } from '@utils/forms'; import type { Attributes } from '@utils/helpers'; import { inheritAriaAttributes, debounceEvent, findItemLabel, inheritAttributes } from '@utils/helpers'; import { printIonWarning } from '@utils/logging'; @@ -33,6 +33,9 @@ export class Input implements ComponentInterface { private inheritedAttributes: Attributes = {}; private isComposing = false; private legacyFormController!: LegacyFormController; + private notchSpacerEl: HTMLElement | undefined; + + private notchController?: NotchController; // This flag ensures we log the deprecation warning at most once. private hasLoggedDeprecationWarning = false; @@ -359,6 +362,11 @@ export class Input implements ComponentInterface { const { el } = this; this.legacyFormController = createLegacyFormController(el); + this.notchController = createNotchController( + el, + () => this.notchSpacerEl, + () => this.labelSlot + ); this.emitStyle(); this.debounceChanged(); @@ -375,6 +383,10 @@ export class Input implements ComponentInterface { this.originalIonInput = this.ionInput; } + componentDidRender() { + this.notchController?.calculateNotchWidth(); + } + disconnectedCallback() { if (Build.isBrowser) { document.dispatchEvent( @@ -383,6 +395,11 @@ export class Input implements ComponentInterface { }) ); } + + if (this.notchController) { + this.notchController.destroy(); + this.notchController = undefined; + } } /** @@ -635,7 +652,7 @@ export class Input implements ComponentInterface {
- diff --git a/core/src/components/input/test/fill/input.e2e.ts b/core/src/components/input/test/fill/input.e2e.ts index 7688efdf711..39c0c1bef7d 100644 --- a/core/src/components/input/test/fill/input.e2e.ts +++ b/core/src/components/input/test/fill/input.e2e.ts @@ -180,3 +180,58 @@ configs({ modes: ['md'] }).forEach(({ title, screenshot, config }) => { }); }); }); + +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { + test.describe(title('input: label slot'), () => { + test('should render the notch correctly with a slotted label', async ({ page }) => { + await page.setContent( + ` + + +
My Label Content
+
+ `, + config + ); + + const input = page.locator('ion-input'); + expect(await input.screenshot()).toMatchSnapshot(screenshot(`input-fill-outline-slotted-label`)); + }); + test('should render the notch correctly with a slotted label after the input was originally hidden', async ({ + page, + }) => { + await page.setContent( + ` + + +
My Label Content
+
+ `, + config + ); + + const input = page.locator('ion-input'); + + await input.evaluate((el: HTMLIonSelectElement) => el.style.removeProperty('display')); + + expect(await input.screenshot()).toMatchSnapshot(screenshot(`input-fill-outline-hidden-slotted-label`)); + }); + }); +}); diff --git a/core/src/components/input/test/fill/input.e2e.ts-snapshots/input-fill-outline-hidden-slotted-label-md-ltr-Mobile-Chrome-linux.png b/core/src/components/input/test/fill/input.e2e.ts-snapshots/input-fill-outline-hidden-slotted-label-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..39518074a14 Binary files /dev/null and b/core/src/components/input/test/fill/input.e2e.ts-snapshots/input-fill-outline-hidden-slotted-label-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/input/test/fill/input.e2e.ts-snapshots/input-fill-outline-hidden-slotted-label-md-ltr-Mobile-Firefox-linux.png b/core/src/components/input/test/fill/input.e2e.ts-snapshots/input-fill-outline-hidden-slotted-label-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..51ddf404110 Binary files /dev/null and b/core/src/components/input/test/fill/input.e2e.ts-snapshots/input-fill-outline-hidden-slotted-label-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/input/test/fill/input.e2e.ts-snapshots/input-fill-outline-hidden-slotted-label-md-ltr-Mobile-Safari-linux.png b/core/src/components/input/test/fill/input.e2e.ts-snapshots/input-fill-outline-hidden-slotted-label-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..991042ab59f Binary files /dev/null and b/core/src/components/input/test/fill/input.e2e.ts-snapshots/input-fill-outline-hidden-slotted-label-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/input/test/fill/input.e2e.ts-snapshots/input-fill-outline-slotted-label-md-ltr-Mobile-Chrome-linux.png b/core/src/components/input/test/fill/input.e2e.ts-snapshots/input-fill-outline-slotted-label-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..39518074a14 Binary files /dev/null and b/core/src/components/input/test/fill/input.e2e.ts-snapshots/input-fill-outline-slotted-label-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/input/test/fill/input.e2e.ts-snapshots/input-fill-outline-slotted-label-md-ltr-Mobile-Firefox-linux.png b/core/src/components/input/test/fill/input.e2e.ts-snapshots/input-fill-outline-slotted-label-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..51ddf404110 Binary files /dev/null and b/core/src/components/input/test/fill/input.e2e.ts-snapshots/input-fill-outline-slotted-label-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/input/test/fill/input.e2e.ts-snapshots/input-fill-outline-slotted-label-md-ltr-Mobile-Safari-linux.png b/core/src/components/input/test/fill/input.e2e.ts-snapshots/input-fill-outline-slotted-label-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..991042ab59f Binary files /dev/null and b/core/src/components/input/test/fill/input.e2e.ts-snapshots/input-fill-outline-slotted-label-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select/select.tsx b/core/src/components/select/select.tsx index 39831d66329..b14c498988c 100644 --- a/core/src/components/select/select.tsx +++ b/core/src/components/select/select.tsx @@ -1,9 +1,8 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; import { Component, Element, Event, Host, Method, Prop, State, Watch, h, forceUpdate } from '@stencil/core'; -import { win } from '@utils/browser'; -import type { LegacyFormController } from '@utils/forms'; -import { createLegacyFormController } from '@utils/forms'; -import { findItemLabel, focusElement, getAriaLabel, renderHiddenInput, inheritAttributes, raf } from '@utils/helpers'; +import type { LegacyFormController, NotchController } from '@utils/forms'; +import { createLegacyFormController, createNotchController } from '@utils/forms'; +import { findItemLabel, focusElement, getAriaLabel, renderHiddenInput, inheritAttributes } from '@utils/helpers'; import type { Attributes } from '@utils/helpers'; import { printIonWarning } from '@utils/logging'; import { actionSheetController, alertController, popoverController } from '@utils/overlays'; @@ -58,7 +57,8 @@ export class Select implements ComponentInterface { private inheritedAttributes: Attributes = {}; private nativeWrapperEl: HTMLElement | undefined; private notchSpacerEl: HTMLElement | undefined; - private notchVisibilityIO: IntersectionObserver | undefined; + + private notchController?: NotchController; // This flag ensures we log the deprecation warning at most once. private hasLoggedDeprecationWarning = false; @@ -245,6 +245,11 @@ export class Select implements ComponentInterface { const { el } = this; this.legacyFormController = createLegacyFormController(el); + this.notchController = createNotchController( + el, + () => this.notchSpacerEl, + () => this.labelSlot + ); this.updateOverlayOptions(); this.emitStyle(); @@ -267,6 +272,11 @@ export class Select implements ComponentInterface { this.mutationO.disconnect(); this.mutationO = undefined; } + + if (this.notchController) { + this.notchController.destroy(); + this.notchController = undefined; + } } /** @@ -746,17 +756,7 @@ export class Select implements ComponentInterface { } componentDidRender() { - if (this.needsExplicitNotchWidth()) { - /** - * Run this the frame after - * the browser has re-painted the select. - * Otherwise, the label element may have a width - * of 0 and the IntersectionObserver will be used. - */ - raf(() => { - this.setNotchWidth(); - }); - } + this.notchController?.calculateNotchWidth(); } /** @@ -777,120 +777,6 @@ export class Select implements ComponentInterface { return this.label !== undefined || this.labelSlot !== null; } - private needsExplicitNotchWidth() { - if ( - /** - * If the notch is not being used - * then we do not need to set the notch width. - */ - this.notchSpacerEl === undefined || - /** - * If either the label property is being - * used or the label slot is not defined, - * then we do not need to estimate the notch width. - */ - this.label !== undefined || - this.labelSlot === null - ) { - return false; - } - - return true; - } - - /** - * When using a label prop we can render - * the label value inside of the notch and - * let the browser calculate the size of the notch. - * However, we cannot render the label slot in multiple - * places so we need to manually calculate the notch dimension - * based on the size of the slotted content. - * - * This function should only be used to set the notch width - * on slotted label content. The notch width for label prop - * content is automatically calculated based on the - * intrinsic size of the label text. - */ - private setNotchWidth() { - const { el, notchSpacerEl } = this; - - if (notchSpacerEl === undefined) { - return; - } - - if (!this.needsExplicitNotchWidth()) { - notchSpacerEl.style.removeProperty('width'); - return; - } - - const width = this.labelSlot!.scrollWidth; - if ( - /** - * If the computed width of the label is 0 - * and notchSpacerEl's offsetParent is null - * then that means the element is hidden. - * As a result, we need to wait for the element - * to become visible before setting the notch width. - * - * We do not check el.offsetParent because - * that can be null if ion-select has - * position: fixed applied to it. - * notchSpacerEl does not have position: fixed. - */ - width === 0 && - notchSpacerEl.offsetParent === null && - win !== undefined && - 'IntersectionObserver' in win - ) { - /** - * If there is an IO already attached - * then that will update the notch - * once the element becomes visible. - * As a result, there is no need to create - * another one. - */ - if (this.notchVisibilityIO !== undefined) { - return; - } - - const io = (this.notchVisibilityIO = new IntersectionObserver( - (ev) => { - /** - * If the element is visible then we - * can try setting the notch width again. - */ - if (ev[0].intersectionRatio === 1) { - this.setNotchWidth(); - io.disconnect(); - this.notchVisibilityIO = undefined; - } - }, - /** - * Set the root to be the select - * This causes the IO callback - * to be fired in WebKit as soon as the element - * is visible. If we used the default root value - * then WebKit would only fire the IO callback - * after any animations (such as a modal transition) - * finished, and there would potentially be a flicker. - */ - { threshold: 0.01, root: el } - )); - - io.observe(notchSpacerEl); - return; - } - - /** - * If the element is visible then we can set the notch width. - * The notch is only visible when the label is scaled, - * which is why we multiply the width by 0.75 as this is - * the same amount the label element is scaled by in the - * select CSS (See $select-floating-label-scale in select.vars.scss). - */ - notchSpacerEl.style.setProperty('width', `${width * 0.75}px`); - } - /** * Renders the border container * when fill="outline". diff --git a/core/src/utils/forms/index.ts b/core/src/utils/forms/index.ts index d7c63744bdc..f219f3978a9 100644 --- a/core/src/utils/forms/index.ts +++ b/core/src/utils/forms/index.ts @@ -1 +1,2 @@ export * from './form-controller'; +export * from './notch-controller'; diff --git a/core/src/utils/forms/notch-controller.ts b/core/src/utils/forms/notch-controller.ts new file mode 100644 index 00000000000..1507f1f3763 --- /dev/null +++ b/core/src/utils/forms/notch-controller.ts @@ -0,0 +1,177 @@ +import { win } from '@utils/browser'; +import { raf } from '@utils/helpers'; + +type NotchElement = HTMLIonInputElement | HTMLIonSelectElement; + +/** + * A utility to calculate the size of an outline notch + * width relative to the content passed. This is used in + * components such as `ion-select` with `fill="outline"` + * where we need to pass slotted HTML content. This is not + * needed when rendering plaintext content because we can + * render the plaintext again hidden with `opacity: 0` inside + * of the notch. As a result we can rely on the intrinsic size + * of the element to correctly compute the notch width. We + * cannot do this with slotted content because we cannot project + * it into 2 places at once. + * + * @internal + * @param el: The host element + * @param getNotchSpacerEl: A function that returns a reference to the notch spacer element inside of the component template. + * @param getLabelSlot: A function that returns a reference to the slotted content. + */ +export const createNotchController = ( + el: NotchElement, + getNotchSpacerEl: () => HTMLElement | undefined, + getLabelSlot: () => Element | null +): NotchController => { + let notchVisibilityIO: IntersectionObserver | undefined; + + const needsExplicitNotchWidth = () => { + const notchSpacerEl = getNotchSpacerEl(); + + if ( + /** + * If the notch is not being used + * then we do not need to set the notch width. + */ + notchSpacerEl === undefined || + /** + * If either the label property is being + * used or the label slot is not defined, + * then we do not need to estimate the notch width. + */ + el.label !== undefined || + getLabelSlot() === null + ) { + return false; + } + + return true; + }; + + const calculateNotchWidth = () => { + if (needsExplicitNotchWidth()) { + /** + * Run this the frame after + * the browser has re-painted the host element. + * Otherwise, the label element may have a width + * of 0 and the IntersectionObserver will be used. + */ + raf(() => { + setNotchWidth(); + }); + } + }; + + /** + * When using a label prop we can render + * the label value inside of the notch and + * let the browser calculate the size of the notch. + * However, we cannot render the label slot in multiple + * places so we need to manually calculate the notch dimension + * based on the size of the slotted content. + * + * This function should only be used to set the notch width + * on slotted label content. The notch width for label prop + * content is automatically calculated based on the + * intrinsic size of the label text. + */ + const setNotchWidth = () => { + const notchSpacerEl = getNotchSpacerEl(); + + if (notchSpacerEl === undefined) { + return; + } + + if (!needsExplicitNotchWidth()) { + notchSpacerEl.style.removeProperty('width'); + return; + } + + const width = getLabelSlot()!.scrollWidth; + + if ( + /** + * If the computed width of the label is 0 + * and notchSpacerEl's offsetParent is null + * then that means the element is hidden. + * As a result, we need to wait for the element + * to become visible before setting the notch width. + * + * We do not check el.offsetParent because + * that can be null if the host element has + * position: fixed applied to it. + * notchSpacerEl does not have position: fixed. + */ + width === 0 && + notchSpacerEl.offsetParent === null && + win !== undefined && + 'IntersectionObserver' in win + ) { + /** + * If there is an IO already attached + * then that will update the notch + * once the element becomes visible. + * As a result, there is no need to create + * another one. + */ + if (notchVisibilityIO !== undefined) { + return; + } + + const io = (notchVisibilityIO = new IntersectionObserver( + (ev) => { + /** + * If the element is visible then we + * can try setting the notch width again. + */ + if (ev[0].intersectionRatio === 1) { + setNotchWidth(); + io.disconnect(); + notchVisibilityIO = undefined; + } + }, + /** + * Set the root to be the host element + * This causes the IO callback + * to be fired in WebKit as soon as the element + * is visible. If we used the default root value + * then WebKit would only fire the IO callback + * after any animations (such as a modal transition) + * finished, and there would potentially be a flicker. + */ + { threshold: 0.01, root: el } + )); + + io.observe(notchSpacerEl); + return; + } + + /** + * If the element is visible then we can set the notch width. + * The notch is only visible when the label is scaled, + * which is why we multiply the width by 0.75 as this is + * the same amount the label element is scaled by in the host CSS. + * (For ion-select, see $select-floating-label-scale in select.vars.scss). + */ + notchSpacerEl.style.setProperty('width', `${width * 0.75}px`); + }; + + const destroy = () => { + if (notchVisibilityIO) { + notchVisibilityIO.disconnect(); + notchVisibilityIO = undefined; + } + }; + + return { + calculateNotchWidth, + destroy, + }; +}; + +export type NotchController = { + calculateNotchWidth: () => void; + destroy: () => void; +};