Skip to content

feat(input): add workaround for dynamic slot content #27636

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 19 commits into from
Jun 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion core/src/components/input/input.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
21 changes: 17 additions & 4 deletions core/src/components/input/input.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -651,7 +659,12 @@ export class Input implements ComponentInterface {
return [
<div class="input-outline-container">
<div class="input-outline-start"></div>
<div class="input-outline-notch">
<div
class={{
'input-outline-notch': true,
'input-outline-notch-hidden': !this.hasLabel,
}}
>
<div class="notch-spacer" aria-hidden="true" ref={(el) => (this.notchSpacerEl = el)}>
{this.label}
</div>
Expand Down
13 changes: 13 additions & 0 deletions core/src/components/input/test/fill/input.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`
<ion-input fill="outline" label-placement="stacked" aria-label="my input"></ion-input>
`,
config
);

const notchCutout = page.locator('ion-input .input-outline-notch');
await expect(notchCutout).toBeHidden();
});
});
});
27 changes: 27 additions & 0 deletions core/src/components/input/test/label-placement/input.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`
<ion-input fill="solid" label-placement="stacked" placeholder="Text Input"></ion-input>
`,
config
);

const input = page.locator('ion-input');

await input.evaluate((el: HTMLIonInputElement) => {
const labelEl = document.createElement('div');
labelEl.slot = 'label';
labelEl.innerHTML = 'Email <span class="required" style="color: red">*</span';

el.appendChild(labelEl);
});

await page.waitForChanges();

expect(await input.screenshot()).toMatchSnapshot(screenshot(`input-async-label`));
});
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
41 changes: 41 additions & 0 deletions core/src/components/input/test/slot/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,49 @@ <h2>Outline / Floating</h2>
<div slot="label">Email <span class="required">*</span></div>
</ion-input>
</div>

<div class="grid-item">
<h2>Outline / Floating / Async</h2>
<ion-input id="solid-async" label-placement="floating" fill="outline" value="[email protected]"></ion-input>
</div>
</div>

<ion-button onclick="addSlot()">Add Slotted Content</ion-button>
<ion-button onclick="updateSlot()">Update Slotted Content</ion-button>
<ion-button onclick="removeSlot()">Remove Slotted Content</ion-button>
</ion-content>
</ion-app>

<script>
const solidAsync = document.querySelector('#solid-async');

const getSlottedContent = () => {
return solidAsync.querySelector('[slot="label"]');
};

const addSlot = () => {
if (getSlottedContent() === null) {
const labelEl = document.createElement('div');
labelEl.slot = 'label';
labelEl.innerHTML = 'Email <span class="required">*</span>';

solidAsync.appendChild(labelEl);
}
};

const removeSlot = () => {
if (getSlottedContent() !== null) {
solidAsync.querySelector('[slot="label"]').remove();
}
};

const updateSlot = () => {
const slottedContent = getSlottedContent();

if (slottedContent !== null) {
slottedContent.textContent = 'This is my really really really long text';
}
};
</script>
</body>
</html>
118 changes: 118 additions & 0 deletions core/src/utils/slot-mutation-controller.ts
Original file line number Diff line number Diff line change
@@ -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;
};