Skip to content

feat(input): add experimental label slot #27650

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 10 commits into from
Jun 15, 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
4 changes: 2 additions & 2 deletions core/src/components.d.ts
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
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 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
Original file line number Diff line number Diff line change
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="[email protected]"></ion-input><br />
Expand Down
68 changes: 68 additions & 0 deletions core/src/components/input/test/fill/input.e2e.ts
Original file line number Diff line number Diff line change
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();
});
});
});
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.
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.
54 changes: 54 additions & 0 deletions core/src/components/input/test/input.spec.ts
Original file line number Diff line number Diff line change
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');
});
});
Loading