Skip to content

Commit eb95367

Browse files
authored
feat(input): add workaround for dynamic slot content (#27636)
1 parent 71b0214 commit eb95367

9 files changed

+218
-5
lines changed

core/src/components/input/input.scss

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,8 @@
477477
* then the element should be hidden otherwise
478478
* there will be additional margins added.
479479
*/
480-
.label-text-wrapper-hidden {
480+
.label-text-wrapper-hidden,
481+
.input-outline-notch-hidden {
481482
display: none;
482483
}
483484

core/src/components/input/input.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import type { ComponentInterface, EventEmitter } from '@stencil/core';
2-
import { Build, Component, Element, Event, Host, Method, Prop, State, Watch, h } from '@stencil/core';
2+
import { Build, Component, Element, Event, Host, Method, Prop, State, Watch, forceUpdate, h } from '@stencil/core';
33
import type { LegacyFormController, NotchController } from '@utils/forms';
44
import { createLegacyFormController, createNotchController } from '@utils/forms';
55
import type { Attributes } from '@utils/helpers';
66
import { inheritAriaAttributes, debounceEvent, findItemLabel, inheritAttributes } from '@utils/helpers';
77
import { printIonWarning } from '@utils/logging';
8+
import { createSlotMutationController } from '@utils/slot-mutation-controller';
9+
import type { SlotMutationController } from '@utils/slot-mutation-controller';
810
import { createColorClasses, hostContext } from '@utils/theme';
911
import { closeCircle, closeSharp } from 'ionicons/icons';
1012

@@ -33,9 +35,9 @@ export class Input implements ComponentInterface {
3335
private inheritedAttributes: Attributes = {};
3436
private isComposing = false;
3537
private legacyFormController!: LegacyFormController;
36-
private notchSpacerEl: HTMLElement | undefined;
37-
38+
private slotMutationController?: SlotMutationController;
3839
private notchController?: NotchController;
40+
private notchSpacerEl: HTMLElement | undefined;
3941

4042
// This flag ensures we log the deprecation warning at most once.
4143
private hasLoggedDeprecationWarning = false;
@@ -362,6 +364,7 @@ export class Input implements ComponentInterface {
362364
const { el } = this;
363365

364366
this.legacyFormController = createLegacyFormController(el);
367+
this.slotMutationController = createSlotMutationController(el, 'label', () => forceUpdate(this));
365368
this.notchController = createNotchController(
366369
el,
367370
() => this.notchSpacerEl,
@@ -396,6 +399,11 @@ export class Input implements ComponentInterface {
396399
);
397400
}
398401

402+
if (this.slotMutationController) {
403+
this.slotMutationController.destroy();
404+
this.slotMutationController = undefined;
405+
}
406+
399407
if (this.notchController) {
400408
this.notchController.destroy();
401409
this.notchController = undefined;
@@ -651,7 +659,12 @@ export class Input implements ComponentInterface {
651659
return [
652660
<div class="input-outline-container">
653661
<div class="input-outline-start"></div>
654-
<div class="input-outline-notch">
662+
<div
663+
class={{
664+
'input-outline-notch': true,
665+
'input-outline-notch-hidden': !this.hasLabel,
666+
}}
667+
>
655668
<div class="notch-spacer" aria-hidden="true" ref={(el) => (this.notchSpacerEl = el)}>
656669
{this.label}
657670
</div>

core/src/components/input/test/fill/input.e2e.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,4 +234,17 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
234234
expect(await input.screenshot()).toMatchSnapshot(screenshot(`input-fill-outline-hidden-slotted-label`));
235235
});
236236
});
237+
test.describe(title('input: notch cutout'), () => {
238+
test('notch cutout should be hidden when no label is passed', async ({ page }) => {
239+
await page.setContent(
240+
`
241+
<ion-input fill="outline" label-placement="stacked" aria-label="my input"></ion-input>
242+
`,
243+
config
244+
);
245+
246+
const notchCutout = page.locator('ion-input .input-outline-notch');
247+
await expect(notchCutout).toBeHidden();
248+
});
249+
});
237250
});

core/src/components/input/test/label-placement/input.e2e.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,30 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
187187
});
188188
});
189189
});
190+
191+
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
192+
test.describe(title('input: async label'), () => {
193+
test('input should re-render when label slot is added async', async ({ page }) => {
194+
await page.setContent(
195+
`
196+
<ion-input fill="solid" label-placement="stacked" placeholder="Text Input"></ion-input>
197+
`,
198+
config
199+
);
200+
201+
const input = page.locator('ion-input');
202+
203+
await input.evaluate((el: HTMLIonInputElement) => {
204+
const labelEl = document.createElement('div');
205+
labelEl.slot = 'label';
206+
labelEl.innerHTML = 'Email <span class="required" style="color: red">*</span';
207+
208+
el.appendChild(labelEl);
209+
});
210+
211+
await page.waitForChanges();
212+
213+
expect(await input.screenshot()).toMatchSnapshot(screenshot(`input-async-label`));
214+
});
215+
});
216+
});

core/src/components/input/test/slot/index.html

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,49 @@ <h2>Outline / Floating</h2>
9191
<div slot="label">Email <span class="required">*</span></div>
9292
</ion-input>
9393
</div>
94+
95+
<div class="grid-item">
96+
<h2>Outline / Floating / Async</h2>
97+
<ion-input id="solid-async" label-placement="floating" fill="outline" value="[email protected]"></ion-input>
98+
</div>
9499
</div>
100+
101+
<ion-button onclick="addSlot()">Add Slotted Content</ion-button>
102+
<ion-button onclick="updateSlot()">Update Slotted Content</ion-button>
103+
<ion-button onclick="removeSlot()">Remove Slotted Content</ion-button>
95104
</ion-content>
96105
</ion-app>
106+
107+
<script>
108+
const solidAsync = document.querySelector('#solid-async');
109+
110+
const getSlottedContent = () => {
111+
return solidAsync.querySelector('[slot="label"]');
112+
};
113+
114+
const addSlot = () => {
115+
if (getSlottedContent() === null) {
116+
const labelEl = document.createElement('div');
117+
labelEl.slot = 'label';
118+
labelEl.innerHTML = 'Email <span class="required">*</span>';
119+
120+
solidAsync.appendChild(labelEl);
121+
}
122+
};
123+
124+
const removeSlot = () => {
125+
if (getSlottedContent() !== null) {
126+
solidAsync.querySelector('[slot="label"]').remove();
127+
}
128+
};
129+
130+
const updateSlot = () => {
131+
const slottedContent = getSlottedContent();
132+
133+
if (slottedContent !== null) {
134+
slottedContent.textContent = 'This is my really really really long text';
135+
}
136+
};
137+
</script>
97138
</body>
98139
</html>
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { win } from '@utils/browser';
2+
import { raf } from '@utils/helpers';
3+
/**
4+
* Used to update a scoped component that uses emulated slots. This fires when
5+
* content is passed into the slot or when the content inside of a slot changes.
6+
* This is not needed for components using native slots in the Shadow DOM.
7+
* @internal
8+
* @param el The host element to observe
9+
* @param slotName mutationCallback will fire when nodes on this slot change
10+
* @param mutationCallback The callback to fire whenever the slotted content changes
11+
*/
12+
export const createSlotMutationController = (
13+
el: HTMLElement,
14+
slotName: string,
15+
mutationCallback: () => void
16+
): SlotMutationController => {
17+
let hostMutationObserver: MutationObserver | undefined;
18+
let slottedContentMutationObserver: MutationObserver | undefined;
19+
20+
if (win !== undefined && 'MutationObserver' in win) {
21+
hostMutationObserver = new MutationObserver((entries) => {
22+
for (const entry of entries) {
23+
for (const node of entry.addedNodes) {
24+
/**
25+
* Check to see if the added node
26+
* is our slotted content.
27+
*/
28+
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).slot === slotName) {
29+
/**
30+
* If so, we want to watch the slotted
31+
* content itself for changes. This lets us
32+
* detect when content inside of the slot changes.
33+
*/
34+
mutationCallback();
35+
36+
/**
37+
* Adding the listener in an raf
38+
* waits until Stencil moves the slotted element
39+
* into the correct place in the event that
40+
* slotted content is being added.
41+
*/
42+
raf(() => watchForSlotChange(node as HTMLElement));
43+
return;
44+
}
45+
}
46+
}
47+
});
48+
49+
hostMutationObserver.observe(el, {
50+
childList: true,
51+
});
52+
}
53+
54+
/**
55+
* Listen for changes inside of the slotted content.
56+
* We can listen for subtree changes here to be
57+
* informed of text within the slotted content
58+
* changing. Doing this on the host is possible
59+
* but it is much more expensive to do because
60+
* it also listens for changes to the internals
61+
* of the component.
62+
*/
63+
const watchForSlotChange = (slottedEl: HTMLElement) => {
64+
if (slottedContentMutationObserver) {
65+
slottedContentMutationObserver.disconnect();
66+
slottedContentMutationObserver = undefined;
67+
}
68+
69+
slottedContentMutationObserver = new MutationObserver((entries) => {
70+
mutationCallback();
71+
72+
for (const entry of entries) {
73+
for (const node of entry.removedNodes) {
74+
/**
75+
* If the element was removed then we
76+
* need to destroy the MutationObserver
77+
* so the element can be garbage collected.
78+
*/
79+
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).slot === slotName) {
80+
destroySlottedContentObserver();
81+
}
82+
}
83+
}
84+
});
85+
86+
/**
87+
* Listen for changes inside of the element
88+
* as well as anything deep in the tree.
89+
* We listen on the parentElement so that we can
90+
* detect when slotted element itself is removed.
91+
*/
92+
slottedContentMutationObserver.observe(slottedEl.parentElement ?? slottedEl, { subtree: true, childList: true });
93+
};
94+
95+
const destroy = () => {
96+
if (hostMutationObserver) {
97+
hostMutationObserver.disconnect();
98+
hostMutationObserver = undefined;
99+
}
100+
101+
destroySlottedContentObserver();
102+
};
103+
104+
const destroySlottedContentObserver = () => {
105+
if (slottedContentMutationObserver) {
106+
slottedContentMutationObserver.disconnect();
107+
slottedContentMutationObserver = undefined;
108+
}
109+
};
110+
111+
return {
112+
destroy,
113+
};
114+
};
115+
116+
export type SlotMutationController = {
117+
destroy: () => void;
118+
};

0 commit comments

Comments
 (0)