Skip to content

Commit a45395c

Browse files
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: ionic-team/ionic-docs#2997 --------- Co-authored-by: Brandy Carney <brandyscarney@users.noreply.github.com>
1 parent 606a892 commit a45395c

File tree

52 files changed

+721
-165
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+721
-165
lines changed

core/src/components.d.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1214,7 +1214,7 @@ export namespace Components {
12141214
*/
12151215
"inputmode"?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search';
12161216
/**
1217-
* The visible label associated with the input.
1217+
* 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.
12181218
*/
12191219
"label"?: string;
12201220
/**
@@ -5248,7 +5248,7 @@ declare namespace LocalJSX {
52485248
*/
52495249
"inputmode"?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search';
52505250
/**
5251-
* The visible label associated with the input.
5251+
* 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.
52525252
*/
52535253
"label"?: string;
52545254
/**

core/src/components/input/input.md.outline.scss

+10
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,16 @@
172172

173173
opacity: 0;
174174
pointer-events: none;
175+
176+
/**
177+
* The spacer currently inherits
178+
* border-box sizing from the Ionic reset styles.
179+
* However, we do not want to include padding in
180+
* the calculation of the element dimensions.
181+
* This code can be removed if input is updated
182+
* to use the Shadow DOM.
183+
*/
184+
box-sizing: content-box;
175185
}
176186

177187
:host(.input-fill-outline) .input-outline-start {

core/src/components/input/input.scss

+12-1
Original file line numberDiff line numberDiff line change
@@ -463,14 +463,25 @@
463463
* works on block-level elements. A flex item is
464464
* considered blockified (https://www.w3.org/TR/css-display-3/#blockify).
465465
*/
466-
.label-text {
466+
.label-text,
467+
::slotted([slot="label"]) {
467468
text-overflow: ellipsis;
468469

469470
white-space: nowrap;
470471

471472
overflow: hidden;
472473
}
473474

475+
/**
476+
* If no label text is placed into the slot
477+
* then the element should be hidden otherwise
478+
* there will be additional margins added.
479+
*/
480+
.label-text-wrapper-hidden,
481+
.input-outline-notch-hidden {
482+
display: none;
483+
}
484+
474485
.input-wrapper input {
475486
/**
476487
* When the floating label appears on top of the

core/src/components/input/input.tsx

+66-10
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';
3-
import type { LegacyFormController } from '@utils/forms';
4-
import { createLegacyFormController } from '@utils/forms';
2+
import { Build, Component, Element, Event, Host, Method, Prop, State, Watch, forceUpdate, h } from '@stencil/core';
3+
import type { LegacyFormController, NotchController } from '@utils/forms';
4+
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

@@ -16,6 +18,8 @@ import { getCounterText } from './input.utils';
1618

1719
/**
1820
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
21+
*
22+
* @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)
1923
*/
2024
@Component({
2125
tag: 'ion-input',
@@ -31,6 +35,9 @@ export class Input implements ComponentInterface {
3135
private inheritedAttributes: Attributes = {};
3236
private isComposing = false;
3337
private legacyFormController!: LegacyFormController;
38+
private slotMutationController?: SlotMutationController;
39+
private notchController?: NotchController;
40+
private notchSpacerEl: HTMLElement | undefined;
3441

3542
// This flag ensures we log the deprecation warning at most once.
3643
private hasLoggedDeprecationWarning = false;
@@ -165,6 +172,10 @@ export class Input implements ComponentInterface {
165172

166173
/**
167174
* The visible label associated with the input.
175+
*
176+
* Use this if you need to render a plaintext label.
177+
*
178+
* The `label` property will take priority over the `label` slot if both are used.
168179
*/
169180
@Prop() label?: string;
170181

@@ -353,6 +364,12 @@ export class Input implements ComponentInterface {
353364
const { el } = this;
354365

355366
this.legacyFormController = createLegacyFormController(el);
367+
this.slotMutationController = createSlotMutationController(el, 'label', () => forceUpdate(this));
368+
this.notchController = createNotchController(
369+
el,
370+
() => this.notchSpacerEl,
371+
() => this.labelSlot
372+
);
356373

357374
this.emitStyle();
358375
this.debounceChanged();
@@ -369,6 +386,10 @@ export class Input implements ComponentInterface {
369386
this.originalIonInput = this.ionInput;
370387
}
371388

389+
componentDidRender() {
390+
this.notchController?.calculateNotchWidth();
391+
}
392+
372393
disconnectedCallback() {
373394
if (Build.isBrowser) {
374395
document.dispatchEvent(
@@ -377,6 +398,16 @@ export class Input implements ComponentInterface {
377398
})
378399
);
379400
}
401+
402+
if (this.slotMutationController) {
403+
this.slotMutationController.destroy();
404+
this.slotMutationController = undefined;
405+
}
406+
407+
if (this.notchController) {
408+
this.notchController.destroy();
409+
this.notchController = undefined;
410+
}
380411
}
381412

382413
/**
@@ -578,17 +609,37 @@ export class Input implements ComponentInterface {
578609

579610
private renderLabel() {
580611
const { label } = this;
581-
if (label === undefined) {
582-
return;
583-
}
584612

585613
return (
586-
<div class="label-text-wrapper">
587-
<div class="label-text">{this.label}</div>
614+
<div
615+
class={{
616+
'label-text-wrapper': true,
617+
'label-text-wrapper-hidden': !this.hasLabel,
618+
}}
619+
>
620+
{label === undefined ? <slot name="label"></slot> : <div class="label-text">{label}</div>}
588621
</div>
589622
);
590623
}
591624

625+
/**
626+
* Gets any content passed into the `label` slot,
627+
* not the <slot> definition.
628+
*/
629+
private get labelSlot() {
630+
return this.el.querySelector('[slot="label"]');
631+
}
632+
633+
/**
634+
* Returns `true` if label content is provided
635+
* either by a prop or a content. If you want
636+
* to get the plaintext value of the label use
637+
* the `labelText` getter instead.
638+
*/
639+
private get hasLabel() {
640+
return this.label !== undefined || this.labelSlot !== null;
641+
}
642+
592643
/**
593644
* Renders the border container
594645
* when fill="outline".
@@ -608,8 +659,13 @@ export class Input implements ComponentInterface {
608659
return [
609660
<div class="input-outline-container">
610661
<div class="input-outline-start"></div>
611-
<div class="input-outline-notch">
612-
<div class="notch-spacer" aria-hidden="true">
662+
<div
663+
class={{
664+
'input-outline-notch': true,
665+
'input-outline-notch-hidden': !this.hasLabel,
666+
}}
667+
>
668+
<div class="notch-spacer" aria-hidden="true" ref={(el) => (this.notchSpacerEl = el)}>
613669
{this.label}
614670
</div>
615671
</div>

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

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
<main>
1616
<h1>Input - a11y</h1>
1717

18+
<ion-input><div slot="label">Slotted Label</div></ion-input><br />
1819
<ion-input label="my label"></ion-input><br />
1920
<ion-input aria-label="my aria label"></ion-input><br />
2021
<ion-input label="Email" label-placement="stacked" value="hi@ionic.io"></ion-input><br />

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

+68
Original file line numberDiff line numberDiff line change
@@ -180,3 +180,71 @@ configs({ modes: ['md'] }).forEach(({ title, screenshot, config }) => {
180180
});
181181
});
182182
});
183+
184+
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
185+
test.describe(title('input: label slot'), () => {
186+
test('should render the notch correctly with a slotted label', async ({ page }) => {
187+
await page.setContent(
188+
`
189+
<style>
190+
.custom-label {
191+
font-size: 30px;
192+
}
193+
</style>
194+
<ion-input
195+
fill="outline"
196+
label-placement="stacked"
197+
value="apple"
198+
>
199+
<div slot="label" class="custom-label">My Label Content</div>
200+
</ion-input>
201+
`,
202+
config
203+
);
204+
205+
const input = page.locator('ion-input');
206+
expect(await input.screenshot()).toMatchSnapshot(screenshot(`input-fill-outline-slotted-label`));
207+
});
208+
test('should render the notch correctly with a slotted label after the input was originally hidden', async ({
209+
page,
210+
}) => {
211+
await page.setContent(
212+
`
213+
<style>
214+
.custom-label {
215+
font-size: 30px;
216+
}
217+
</style>
218+
<ion-input
219+
fill="outline"
220+
label-placement="stacked"
221+
value="apple"
222+
style="display: none"
223+
>
224+
<div slot="label" class="custom-label">My Label Content</div>
225+
</ion-input>
226+
`,
227+
config
228+
);
229+
230+
const input = page.locator('ion-input');
231+
232+
await input.evaluate((el: HTMLIonSelectElement) => el.style.removeProperty('display'));
233+
234+
expect(await input.screenshot()).toMatchSnapshot(screenshot(`input-fill-outline-hidden-slotted-label`));
235+
});
236+
});
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+
});
250+
});

core/src/components/input/test/input.spec.ts

+54
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,57 @@ describe('input: rendering', () => {
4444
expect(bottomContent).toBe(null);
4545
});
4646
});
47+
48+
/**
49+
* Input uses emulated slots, so the internal
50+
* behavior will not exactly match Select's slots.
51+
* For example, Input does not render an actual `<slot>` element
52+
* internally, so we do not check for that here. Instead,
53+
* we check to see which label text is being used.
54+
* If Input is updated to use Shadow DOM (and therefore native slots),
55+
* then we can update these tests to more closely match the Select tests.
56+
**/
57+
describe('input: label rendering', () => {
58+
it('should render label prop if only prop provided', async () => {
59+
const page = await newSpecPage({
60+
components: [Input],
61+
html: `
62+
<ion-input label="Label Prop Text"></ion-input>
63+
`,
64+
});
65+
66+
const input = page.body.querySelector('ion-input');
67+
68+
const labelText = input.querySelector('.label-text-wrapper');
69+
70+
expect(labelText.textContent).toBe('Label Prop Text');
71+
});
72+
it('should render label slot if only slot provided', async () => {
73+
const page = await newSpecPage({
74+
components: [Input],
75+
html: `
76+
<ion-input><div slot="label">Label Slot Text</div></ion-input>
77+
`,
78+
});
79+
80+
const input = page.body.querySelector('ion-input');
81+
82+
const labelText = input.querySelector('.label-text-wrapper');
83+
84+
expect(labelText.textContent).toBe('Label Slot Text');
85+
});
86+
it('should render label prop if both prop and slot provided', async () => {
87+
const page = await newSpecPage({
88+
components: [Input],
89+
html: `
90+
<ion-input label="Label Prop Text"><div slot="label">Label Slot Text</div></ion-input>
91+
`,
92+
});
93+
94+
const input = page.body.querySelector('ion-input');
95+
96+
const labelText = input.querySelector('.label-text-wrapper');
97+
98+
expect(labelText.textContent).toBe('Label Prop Text');
99+
});
100+
});

0 commit comments

Comments
 (0)