diff --git a/core/src/components/input/input.scss b/core/src/components/input/input.scss index ba8b00312f9..4a18956e01b 100644 --- a/core/src/components/input/input.scss +++ b/core/src/components/input/input.scss @@ -477,7 +477,8 @@ * then the element should be hidden otherwise * there will be additional margins added. */ -.label-text-wrapper-hidden { +.label-text-wrapper-hidden, +.input-outline-notch-hidden { display: none; } diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index b1630ff0bc4..25332f76add 100644 --- a/core/src/components/input/input.tsx +++ b/core/src/components/input/input.tsx @@ -1,10 +1,12 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; -import { Build, Component, Element, Event, Host, Method, Prop, State, Watch, h } from '@stencil/core'; +import { Build, Component, Element, Event, Host, Method, Prop, State, Watch, forceUpdate, h } from '@stencil/core'; 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'; +import { createSlotMutationController } from '@utils/slot-mutation-controller'; +import type { SlotMutationController } from '@utils/slot-mutation-controller'; import { createColorClasses, hostContext } from '@utils/theme'; import { closeCircle, closeSharp } from 'ionicons/icons'; @@ -33,9 +35,9 @@ export class Input implements ComponentInterface { private inheritedAttributes: Attributes = {}; private isComposing = false; private legacyFormController!: LegacyFormController; - private notchSpacerEl: HTMLElement | undefined; - + private slotMutationController?: SlotMutationController; private notchController?: NotchController; + private notchSpacerEl: HTMLElement | undefined; // This flag ensures we log the deprecation warning at most once. private hasLoggedDeprecationWarning = false; @@ -362,6 +364,7 @@ export class Input implements ComponentInterface { const { el } = this; this.legacyFormController = createLegacyFormController(el); + this.slotMutationController = createSlotMutationController(el, 'label', () => forceUpdate(this)); this.notchController = createNotchController( el, () => this.notchSpacerEl, @@ -396,6 +399,11 @@ export class Input implements ComponentInterface { ); } + if (this.slotMutationController) { + this.slotMutationController.destroy(); + this.slotMutationController = undefined; + } + if (this.notchController) { this.notchController.destroy(); this.notchController = undefined; @@ -651,7 +659,12 @@ export class Input implements ComponentInterface { return [
-
+
diff --git a/core/src/components/input/test/fill/input.e2e.ts b/core/src/components/input/test/fill/input.e2e.ts index 39c0c1bef7d..2a7f949f2da 100644 --- a/core/src/components/input/test/fill/input.e2e.ts +++ b/core/src/components/input/test/fill/input.e2e.ts @@ -234,4 +234,17 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co expect(await input.screenshot()).toMatchSnapshot(screenshot(`input-fill-outline-hidden-slotted-label`)); }); }); + test.describe(title('input: notch cutout'), () => { + test('notch cutout should be hidden when no label is passed', async ({ page }) => { + await page.setContent( + ` + + `, + config + ); + + const notchCutout = page.locator('ion-input .input-outline-notch'); + await expect(notchCutout).toBeHidden(); + }); + }); }); diff --git a/core/src/components/input/test/label-placement/input.e2e.ts b/core/src/components/input/test/label-placement/input.e2e.ts index ac1666467fd..3e3565db14f 100644 --- a/core/src/components/input/test/label-placement/input.e2e.ts +++ b/core/src/components/input/test/label-placement/input.e2e.ts @@ -187,3 +187,30 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co }); }); }); + +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { + test.describe(title('input: async label'), () => { + test('input should re-render when label slot is added async', async ({ page }) => { + await page.setContent( + ` + + `, + config + ); + + const input = page.locator('ion-input'); + + await input.evaluate((el: HTMLIonInputElement) => { + const labelEl = document.createElement('div'); + labelEl.slot = 'label'; + labelEl.innerHTML = 'Email *Outline / Floating
Email *
+ +
+

Outline / Floating / Async

+ +
+ + Add Slotted Content + Update Slotted Content + Remove Slotted Content + + diff --git a/core/src/utils/slot-mutation-controller.ts b/core/src/utils/slot-mutation-controller.ts new file mode 100644 index 00000000000..663b781a367 --- /dev/null +++ b/core/src/utils/slot-mutation-controller.ts @@ -0,0 +1,118 @@ +import { win } from '@utils/browser'; +import { raf } from '@utils/helpers'; +/** + * Used to update a scoped component that uses emulated slots. This fires when + * content is passed into the slot or when the content inside of a slot changes. + * This is not needed for components using native slots in the Shadow DOM. + * @internal + * @param el The host element to observe + * @param slotName mutationCallback will fire when nodes on this slot change + * @param mutationCallback The callback to fire whenever the slotted content changes + */ +export const createSlotMutationController = ( + el: HTMLElement, + slotName: string, + mutationCallback: () => void +): SlotMutationController => { + let hostMutationObserver: MutationObserver | undefined; + let slottedContentMutationObserver: MutationObserver | undefined; + + if (win !== undefined && 'MutationObserver' in win) { + hostMutationObserver = new MutationObserver((entries) => { + for (const entry of entries) { + for (const node of entry.addedNodes) { + /** + * Check to see if the added node + * is our slotted content. + */ + if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).slot === slotName) { + /** + * If so, we want to watch the slotted + * content itself for changes. This lets us + * detect when content inside of the slot changes. + */ + mutationCallback(); + + /** + * Adding the listener in an raf + * waits until Stencil moves the slotted element + * into the correct place in the event that + * slotted content is being added. + */ + raf(() => watchForSlotChange(node as HTMLElement)); + return; + } + } + } + }); + + hostMutationObserver.observe(el, { + childList: true, + }); + } + + /** + * Listen for changes inside of the slotted content. + * We can listen for subtree changes here to be + * informed of text within the slotted content + * changing. Doing this on the host is possible + * but it is much more expensive to do because + * it also listens for changes to the internals + * of the component. + */ + const watchForSlotChange = (slottedEl: HTMLElement) => { + if (slottedContentMutationObserver) { + slottedContentMutationObserver.disconnect(); + slottedContentMutationObserver = undefined; + } + + slottedContentMutationObserver = new MutationObserver((entries) => { + mutationCallback(); + + for (const entry of entries) { + for (const node of entry.removedNodes) { + /** + * If the element was removed then we + * need to destroy the MutationObserver + * so the element can be garbage collected. + */ + if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).slot === slotName) { + destroySlottedContentObserver(); + } + } + } + }); + + /** + * Listen for changes inside of the element + * as well as anything deep in the tree. + * We listen on the parentElement so that we can + * detect when slotted element itself is removed. + */ + slottedContentMutationObserver.observe(slottedEl.parentElement ?? slottedEl, { subtree: true, childList: true }); + }; + + const destroy = () => { + if (hostMutationObserver) { + hostMutationObserver.disconnect(); + hostMutationObserver = undefined; + } + + destroySlottedContentObserver(); + }; + + const destroySlottedContentObserver = () => { + if (slottedContentMutationObserver) { + slottedContentMutationObserver.disconnect(); + slottedContentMutationObserver = undefined; + } + }; + + return { + destroy, + }; +}; + +export type SlotMutationController = { + destroy: () => void; +};