Skip to content

Commit

Permalink
feat(input): add experimental label slot (#27650)
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
liamdebeasi and brandyscarney committed Jun 15, 2023
1 parent 606a892 commit a45395c
Show file tree
Hide file tree
Showing 52 changed files with 721 additions and 165 deletions.
4 changes: 2 additions & 2 deletions core/src/components.d.ts
Expand Up @@ -1214,7 +1214,7 @@ export namespace Components {
*/
"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;
/**
Expand Down Expand Up @@ -5248,7 +5248,7 @@ declare namespace LocalJSX {
*/
"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;
/**
Expand Down
10 changes: 10 additions & 0 deletions core/src/components/input/input.md.outline.scss
Expand Up @@ -172,6 +172,16 @@

opacity: 0;
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 {
Expand Down
13 changes: 12 additions & 1 deletion core/src/components/input/input.scss
Expand Up @@ -463,14 +463,25 @@
* works on block-level elements. A flex item is
* considered blockified (https://www.w3.org/TR/css-display-3/#blockify).
*/
.label-text {
.label-text,
::slotted([slot="label"]) {
text-overflow: ellipsis;

white-space: nowrap;

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 {
/**
* When the floating label appears on top of the
Expand Down
76 changes: 66 additions & 10 deletions 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 type { LegacyFormController } from '@utils/forms';
import { createLegacyFormController } from '@utils/forms';
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 All @@ -16,6 +18,8 @@ import { getCounterText } from './input.utils';

/**
* @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({
tag: 'ion-input',
Expand All @@ -31,6 +35,9 @@ export class Input implements ComponentInterface {
private inheritedAttributes: Attributes = {};
private isComposing = false;
private legacyFormController!: LegacyFormController;
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 @@ -165,6 +172,10 @@ export class Input implements ComponentInterface {

/**
* 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;

Expand Down Expand Up @@ -353,6 +364,12 @@ 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,
() => this.labelSlot
);

this.emitStyle();
this.debounceChanged();
Expand All @@ -369,6 +386,10 @@ export class Input implements ComponentInterface {
this.originalIonInput = this.ionInput;
}

componentDidRender() {
this.notchController?.calculateNotchWidth();
}

disconnectedCallback() {
if (Build.isBrowser) {
document.dispatchEvent(
Expand All @@ -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;
}
}

/**
Expand Down Expand Up @@ -578,17 +609,37 @@ export class Input implements ComponentInterface {

private renderLabel() {
const { label } = this;
if (label === undefined) {
return;
}

return (
<div class="label-text-wrapper">
<div class="label-text">{this.label}</div>
<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>
);
}

/**
* 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
* when fill="outline".
Expand All @@ -608,8 +659,13 @@ export class Input implements ComponentInterface {
return [
<div class="input-outline-container">
<div class="input-outline-start"></div>
<div class="input-outline-notch">
<div class="notch-spacer" aria-hidden="true">
<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>
</div>
Expand Down
1 change: 1 addition & 0 deletions core/src/components/input/test/a11y/index.html
Expand Up @@ -15,6 +15,7 @@
<main>
<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 aria-label="my aria label"></ion-input><br />
<ion-input label="Email" label-placement="stacked" value="hi@ionic.io"></ion-input><br />
Expand Down
68 changes: 68 additions & 0 deletions core/src/components/input/test/fill/input.e2e.ts
Expand Up @@ -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();
});
});
});
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
54 changes: 54 additions & 0 deletions core/src/components/input/test/input.spec.ts
Expand Up @@ -44,3 +44,57 @@ describe('input: rendering', () => {
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');
});
});

0 comments on commit a45395c

Please sign in to comment.