feat(input): add experimental label slot (#27650)

Issue number: resolves #27061

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying. -->

Input does not accept custom HTML labels

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

- Input accepts custom HTML labels as an experimental feature. We marked
this as experimental because it makes use of "scoped slots" which is an
emulated version of Web Component slots. As a result, there may be
instances where the slot behavior does not exactly match the native slot
behavior.

Note to reviewers: This is a combination of previously reviewed PRs. The
implementation is complete, so feel free to bikeshed.

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!-- If this introduces a breaking change, please describe the impact
and migration path for existing applications below. -->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->


Docs PR: https://github.com/ionic-team/ionic-docs/pull/2997

---------

Co-authored-by: Brandy Carney <brandyscarney@users.noreply.github.com>
This commit is contained in:
Liam DeBeasi
2023-06-15 15:23:41 -04:00
committed by GitHub
parent 606a892e40
commit a45395cc02
52 changed files with 721 additions and 165 deletions

View File

@ -1214,7 +1214,7 @@ export namespace Components {
*/ */
"inputmode"?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search'; "inputmode"?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search';
/** /**
* The visible label associated with the input. * The visible label associated with the input. Use this if you need to render a plaintext label. The `label` property will take priority over the `label` slot if both are used.
*/ */
"label"?: string; "label"?: string;
/** /**
@ -5248,7 +5248,7 @@ declare namespace LocalJSX {
*/ */
"inputmode"?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search'; "inputmode"?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search';
/** /**
* The visible label associated with the input. * The visible label associated with the input. Use this if you need to render a plaintext label. The `label` property will take priority over the `label` slot if both are used.
*/ */
"label"?: string; "label"?: string;
/** /**

View File

@ -172,6 +172,16 @@
opacity: 0; opacity: 0;
pointer-events: none; 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 { :host(.input-fill-outline) .input-outline-start {

View File

@ -463,7 +463,8 @@
* works on block-level elements. A flex item is * works on block-level elements. A flex item is
* considered blockified (https://www.w3.org/TR/css-display-3/#blockify). * considered blockified (https://www.w3.org/TR/css-display-3/#blockify).
*/ */
.label-text { .label-text,
::slotted([slot="label"]) {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@ -471,6 +472,16 @@
overflow: hidden; overflow: hidden;
} }
/**
* If no label text is placed into the slot
* then the element should be hidden otherwise
* there will be additional margins added.
*/
.label-text-wrapper-hidden,
.input-outline-notch-hidden {
display: none;
}
.input-wrapper input { .input-wrapper input {
/** /**
* When the floating label appears on top of the * When the floating label appears on top of the

View File

@ -1,10 +1,12 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core'; 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 } from '@utils/forms'; import type { LegacyFormController, NotchController } from '@utils/forms';
import { createLegacyFormController } from '@utils/forms'; import { createLegacyFormController, createNotchController } from '@utils/forms';
import type { Attributes } from '@utils/helpers'; import type { Attributes } from '@utils/helpers';
import { inheritAriaAttributes, debounceEvent, findItemLabel, inheritAttributes } from '@utils/helpers'; import { inheritAriaAttributes, debounceEvent, findItemLabel, inheritAttributes } from '@utils/helpers';
import { printIonWarning } from '@utils/logging'; 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 { createColorClasses, hostContext } from '@utils/theme';
import { closeCircle, closeSharp } from 'ionicons/icons'; import { closeCircle, closeSharp } from 'ionicons/icons';
@ -16,6 +18,8 @@ import { getCounterText } from './input.utils';
/** /**
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
*
* @slot label - The label text to associate with the input. Use the `labelPlacement` property to control where the label is placed relative to the input. Use this if you need to render a label with custom HTML. (EXPERIMENTAL)
*/ */
@Component({ @Component({
tag: 'ion-input', tag: 'ion-input',
@ -31,6 +35,9 @@ export class Input implements ComponentInterface {
private inheritedAttributes: Attributes = {}; private inheritedAttributes: Attributes = {};
private isComposing = false; private isComposing = false;
private legacyFormController!: LegacyFormController; private legacyFormController!: LegacyFormController;
private slotMutationController?: SlotMutationController;
private notchController?: NotchController;
private notchSpacerEl: HTMLElement | undefined;
// This flag ensures we log the deprecation warning at most once. // This flag ensures we log the deprecation warning at most once.
private hasLoggedDeprecationWarning = false; private hasLoggedDeprecationWarning = false;
@ -165,6 +172,10 @@ export class Input implements ComponentInterface {
/** /**
* The visible label associated with the input. * The visible label associated with the input.
*
* Use this if you need to render a plaintext label.
*
* The `label` property will take priority over the `label` slot if both are used.
*/ */
@Prop() label?: string; @Prop() label?: string;
@ -353,6 +364,12 @@ export class Input implements ComponentInterface {
const { el } = this; const { el } = this;
this.legacyFormController = createLegacyFormController(el); this.legacyFormController = createLegacyFormController(el);
this.slotMutationController = createSlotMutationController(el, 'label', () => forceUpdate(this));
this.notchController = createNotchController(
el,
() => this.notchSpacerEl,
() => this.labelSlot
);
this.emitStyle(); this.emitStyle();
this.debounceChanged(); this.debounceChanged();
@ -369,6 +386,10 @@ export class Input implements ComponentInterface {
this.originalIonInput = this.ionInput; this.originalIonInput = this.ionInput;
} }
componentDidRender() {
this.notchController?.calculateNotchWidth();
}
disconnectedCallback() { disconnectedCallback() {
if (Build.isBrowser) { if (Build.isBrowser) {
document.dispatchEvent( document.dispatchEvent(
@ -377,6 +398,16 @@ export class Input implements ComponentInterface {
}) })
); );
} }
if (this.slotMutationController) {
this.slotMutationController.destroy();
this.slotMutationController = undefined;
}
if (this.notchController) {
this.notchController.destroy();
this.notchController = undefined;
}
} }
/** /**
@ -578,17 +609,37 @@ export class Input implements ComponentInterface {
private renderLabel() { private renderLabel() {
const { label } = this; const { label } = this;
if (label === undefined) {
return;
}
return ( return (
<div class="label-text-wrapper"> <div
<div class="label-text">{this.label}</div> class={{
'label-text-wrapper': true,
'label-text-wrapper-hidden': !this.hasLabel,
}}
>
{label === undefined ? <slot name="label"></slot> : <div class="label-text">{label}</div>}
</div> </div>
); );
} }
/**
* Gets any content passed into the `label` slot,
* not the <slot> definition.
*/
private get labelSlot() {
return this.el.querySelector('[slot="label"]');
}
/**
* Returns `true` if label content is provided
* either by a prop or a content. If you want
* to get the plaintext value of the label use
* the `labelText` getter instead.
*/
private get hasLabel() {
return this.label !== undefined || this.labelSlot !== null;
}
/** /**
* Renders the border container * Renders the border container
* when fill="outline". * when fill="outline".
@ -608,8 +659,13 @@ export class Input implements ComponentInterface {
return [ return [
<div class="input-outline-container"> <div class="input-outline-container">
<div class="input-outline-start"></div> <div class="input-outline-start"></div>
<div class="input-outline-notch"> <div
<div class="notch-spacer" aria-hidden="true"> 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} {this.label}
</div> </div>
</div> </div>

View File

@ -15,6 +15,7 @@
<main> <main>
<h1>Input - a11y</h1> <h1>Input - a11y</h1>
<ion-input><div slot="label">Slotted Label</div></ion-input><br />
<ion-input label="my label"></ion-input><br /> <ion-input label="my label"></ion-input><br />
<ion-input aria-label="my aria label"></ion-input><br /> <ion-input aria-label="my aria label"></ion-input><br />
<ion-input label="Email" label-placement="stacked" value="hi@ionic.io"></ion-input><br /> <ion-input label="Email" label-placement="stacked" value="hi@ionic.io"></ion-input><br />

View File

@ -180,3 +180,71 @@ 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(
`
<style>
.custom-label {
font-size: 30px;
}
</style>
<ion-input
fill="outline"
label-placement="stacked"
value="apple"
>
<div slot="label" class="custom-label">My Label Content</div>
</ion-input>
`,
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(
`
<style>
.custom-label {
font-size: 30px;
}
</style>
<ion-input
fill="outline"
label-placement="stacked"
value="apple"
style="display: none"
>
<div slot="label" class="custom-label">My Label Content</div>
</ion-input>
`,
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`));
});
});
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();
});
});
});

View File

@ -44,3 +44,57 @@ describe('input: rendering', () => {
expect(bottomContent).toBe(null); expect(bottomContent).toBe(null);
}); });
}); });
/**
* Input uses emulated slots, so the internal
* behavior will not exactly match Select's slots.
* For example, Input does not render an actual `<slot>` element
* internally, so we do not check for that here. Instead,
* we check to see which label text is being used.
* If Input is updated to use Shadow DOM (and therefore native slots),
* then we can update these tests to more closely match the Select tests.
**/
describe('input: label rendering', () => {
it('should render label prop if only prop provided', async () => {
const page = await newSpecPage({
components: [Input],
html: `
<ion-input label="Label Prop Text"></ion-input>
`,
});
const input = page.body.querySelector('ion-input');
const labelText = input.querySelector('.label-text-wrapper');
expect(labelText.textContent).toBe('Label Prop Text');
});
it('should render label slot if only slot provided', async () => {
const page = await newSpecPage({
components: [Input],
html: `
<ion-input><div slot="label">Label Slot Text</div></ion-input>
`,
});
const input = page.body.querySelector('ion-input');
const labelText = input.querySelector('.label-text-wrapper');
expect(labelText.textContent).toBe('Label Slot Text');
});
it('should render label prop if both prop and slot provided', async () => {
const page = await newSpecPage({
components: [Input],
html: `
<ion-input label="Label Prop Text"><div slot="label">Label Slot Text</div></ion-input>
`,
});
const input = page.body.querySelector('ion-input');
const labelText = input.querySelector('.label-text-wrapper');
expect(labelText.textContent).toBe('Label Prop Text');
});
});

View File

@ -14,17 +14,6 @@ configs().forEach(({ title, screenshot, config }) => {
const input = page.locator('ion-input'); const input = page.locator('ion-input');
expect(await input.screenshot()).toMatchSnapshot(screenshot(`input-placement-start`)); expect(await input.screenshot()).toMatchSnapshot(screenshot(`input-placement-start`));
}); });
test('long label should truncate', async ({ page }) => {
await page.setContent(
`
<ion-input label="Email Email Email Email Email Email Email Email Email Email Email Email" value="example@ionic.io" label-placement="start"></ion-input>
`,
config
);
const input = page.locator('ion-input');
expect(await input.screenshot()).toMatchSnapshot(screenshot(`input-placement-start-long-label`));
});
}); });
test.describe(title('input: label placement end'), () => { test.describe(title('input: label placement end'), () => {
test('label should appear on the ending side of the input', async ({ page }) => { test('label should appear on the ending side of the input', async ({ page }) => {
@ -38,16 +27,6 @@ configs().forEach(({ title, screenshot, config }) => {
const input = page.locator('ion-input'); const input = page.locator('ion-input');
expect(await input.screenshot()).toMatchSnapshot(screenshot(`input-placement-end`)); expect(await input.screenshot()).toMatchSnapshot(screenshot(`input-placement-end`));
}); });
test('long label should truncate', async ({ page }) => {
await page.setContent(
`
<ion-input label="Email Email Email Email Email Email Email Email Email Email Email Email" value="example@ionic.io" label-placement="end"></ion-input>
`,
config
);
const input = page.locator('ion-input');
expect(await input.screenshot()).toMatchSnapshot(screenshot(`input-placement-end-long-label`));
});
}); });
test.describe(title('input: label placement fixed'), () => { test.describe(title('input: label placement fixed'), () => {
test('label should appear on the starting side of the input, have a fixed width, and show ellipses', async ({ test('label should appear on the starting side of the input, have a fixed width, and show ellipses', async ({
@ -179,3 +158,59 @@ configs({ modes: ['md'] }).forEach(({ title, screenshot, config }) => {
}); });
}); });
}); });
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('input: label overflow'), () => {
test('label property should be truncated with an ellipsis', async ({ page }) => {
await page.setContent(
`
<ion-input label="Label Label Label Label Label" placeholder="Text Input"></ion-input>
`,
config
);
const input = page.locator('ion-input');
expect(await input.screenshot()).toMatchSnapshot(screenshot(`input-label-truncate`));
});
test('label slot should be truncated with an ellipsis', async ({ page }) => {
await page.setContent(
`
<ion-input placeholder="Text Input">
<div slot="label">Label Label Label Label Label</div>
</ion-input>
`,
config
);
const input = page.locator('ion-input');
expect(await input.screenshot()).toMatchSnapshot(screenshot(`input-label-slot-truncate`));
});
});
});
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`));
});
});
});

View File

@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Input - Slot</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<style>
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(250px, 1fr));
grid-row-gap: 20px;
grid-column-gap: 20px;
}
h2 {
font-size: 12px;
font-weight: normal;
color: #6f7378;
margin-top: 10px;
}
@media screen and (max-width: 800px) {
.grid {
grid-template-columns: 1fr;
padding: 0;
}
}
.required {
color: red;
}
</style>
</head>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Input - Slot</ion-title>
</ion-toolbar>
</ion-header>
<ion-content id="content" class="ion-padding">
<div class="grid">
<div class="grid-item">
<h2>No Fill / Start</h2>
<ion-input label-placement="start" value="hi@ionic.io">
<div slot="label">Email <span class="required">*</span></div>
</ion-input>
</div>
<div class="grid-item">
<h2>Solid / Start</h2>
<ion-input label-placement="start" fill="solid" value="hi@ionic.io">
<div slot="label">Email <span class="required">*</span></div>
</ion-input>
</div>
<div class="grid-item">
<h2>Outline / Start</h2>
<ion-input label-placement="start" fill="outline" value="hi@ionic.io">
<div slot="label">Email <span class="required">*</span></div>
</ion-input>
</div>
<div class="grid-item">
<h2>No Fill / Floating</h2>
<ion-input label-placement="floating" value="hi@ionic.io">
<div slot="label">Email <span class="required">*</span></div>
</ion-input>
</div>
<div class="grid-item">
<h2>Solid / Floating</h2>
<ion-input label-placement="floating" fill="solid" value="hi@ionic.io">
<div slot="label">Email <span class="required">*</span></div>
</ion-input>
</div>
<div class="grid-item">
<h2>Outline / Floating</h2>
<ion-input label-placement="floating" fill="outline" value="hi@ionic.io">
<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="hi@ionic.io"></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>

View File

@ -1,9 +1,8 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core'; import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Method, Prop, State, Watch, h, forceUpdate } 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, NotchController } from '@utils/forms';
import type { LegacyFormController } from '@utils/forms'; import { createLegacyFormController, createNotchController } from '@utils/forms';
import { createLegacyFormController } from '@utils/forms'; import { findItemLabel, focusElement, getAriaLabel, renderHiddenInput, inheritAttributes } from '@utils/helpers';
import { findItemLabel, focusElement, getAriaLabel, renderHiddenInput, inheritAttributes, raf } from '@utils/helpers';
import type { Attributes } from '@utils/helpers'; import type { Attributes } from '@utils/helpers';
import { printIonWarning } from '@utils/logging'; import { printIonWarning } from '@utils/logging';
import { actionSheetController, alertController, popoverController } from '@utils/overlays'; import { actionSheetController, alertController, popoverController } from '@utils/overlays';
@ -33,7 +32,7 @@ import type { SelectChangeEventDetail, SelectInterface, SelectCompareFn } from '
/** /**
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
* *
* @slot label - The label text to associate with the select. Use the "labelPlacement" property to control where the label is placed relative to the select. Use this if you need to render a label with custom HTML. * @slot label - The label text to associate with the select. Use the `labelPlacement` property to control where the label is placed relative to the select. Use this if you need to render a label with custom HTML.
* *
* @part placeholder - The text displayed in the select when there is no value. * @part placeholder - The text displayed in the select when there is no value.
* @part text - The displayed value of the select. * @part text - The displayed value of the select.
@ -58,7 +57,8 @@ export class Select implements ComponentInterface {
private inheritedAttributes: Attributes = {}; private inheritedAttributes: Attributes = {};
private nativeWrapperEl: HTMLElement | undefined; private nativeWrapperEl: HTMLElement | undefined;
private notchSpacerEl: HTMLElement | undefined; private notchSpacerEl: HTMLElement | undefined;
private notchVisibilityIO: IntersectionObserver | undefined;
private notchController?: NotchController;
// This flag ensures we log the deprecation warning at most once. // This flag ensures we log the deprecation warning at most once.
private hasLoggedDeprecationWarning = false; private hasLoggedDeprecationWarning = false;
@ -245,6 +245,11 @@ export class Select implements ComponentInterface {
const { el } = this; const { el } = this;
this.legacyFormController = createLegacyFormController(el); this.legacyFormController = createLegacyFormController(el);
this.notchController = createNotchController(
el,
() => this.notchSpacerEl,
() => this.labelSlot
);
this.updateOverlayOptions(); this.updateOverlayOptions();
this.emitStyle(); this.emitStyle();
@ -267,6 +272,11 @@ export class Select implements ComponentInterface {
this.mutationO.disconnect(); this.mutationO.disconnect();
this.mutationO = undefined; this.mutationO = undefined;
} }
if (this.notchController) {
this.notchController.destroy();
this.notchController = undefined;
}
} }
/** /**
@ -746,17 +756,7 @@ export class Select implements ComponentInterface {
} }
componentDidRender() { componentDidRender() {
if (this.needsExplicitNotchWidth()) { this.notchController?.calculateNotchWidth();
/**
* 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();
});
}
} }
/** /**
@ -777,120 +777,6 @@ export class Select implements ComponentInterface {
return this.label !== undefined || this.labelSlot !== null; 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 * Renders the border container
* when fill="outline". * when fill="outline".

View File

@ -1 +1,2 @@
export * from './form-controller'; export * from './form-controller';
export * from './notch-controller';

View File

@ -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;
};

View File

@ -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;
};