feat(input, textarea, select): add start and end slots (#28583)

Issue number: Resolves #26297

---------

<!-- 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. -->

With the modern form control syntax, it is not possible to add icon
buttons or other decorators to the sides of `ion-input`, `ion-textarea`,
or `ion-select`, as you can with `ion-item`.

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

`start` and `end` slots added to each component.

This PR is a combination of several others that were already approved.
If needed, it might be easiest to review the PRs individually by looking
at the commit history here.

## 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/3271

Dev build: `7.5.4-dev.11701112913.1ea61220`

---------

Co-authored-by: ionitron <hi@ionicframework.com>
Co-authored-by: Maria Hutt <thetaPC@users.noreply.github.com>
Co-authored-by: Liam DeBeasi <liamdebeasi@users.noreply.github.com>
This commit is contained in:
Amanda Johnston
2023-12-01 14:54:10 -06:00
committed by GitHub
parent b757970d23
commit 357b8b2beb
97 changed files with 1070 additions and 129 deletions

View File

@ -89,9 +89,7 @@
/**
* This makes the label sit above the input.
*/
:host(.has-focus.input-fill-outline.input-label-placement-floating) .label-text-wrapper,
:host(.has-value.input-fill-outline.input-label-placement-floating) .label-text-wrapper,
:host(.input-fill-outline.input-label-placement-stacked) .label-text-wrapper {
:host(.label-floating.input-fill-outline) .label-text-wrapper {
@include transform(translateY(-32%), scale(#{$form-control-label-stacked-scale}));
@include margin(0);
@ -216,8 +214,6 @@
* the floating/stacked label. We simulate this "cut out"
* by removing the top border from the notch fragment.
*/
:host(.has-focus.input-fill-outline.input-label-placement-floating) .input-outline-notch,
:host(.has-value.input-fill-outline.input-label-placement-floating) .input-outline-notch,
:host(.input-fill-outline.input-label-placement-stacked) .input-outline-notch {
:host(.label-floating.input-fill-outline) .input-outline-notch {
border-top: none;
}

View File

@ -67,9 +67,7 @@
// Input Label
// ----------------------------------------------------------------
:host(.input-fill-solid.input-label-placement-stacked) .label-text-wrapper,
:host(.has-focus.input-fill-solid.input-label-placement-floating) .label-text-wrapper,
:host(.has-value.input-fill-solid.input-label-placement-floating) .label-text-wrapper {
:host(.label-floating.input-fill-solid.input-label-placement-floating) .label-text-wrapper {
/**
* Label text should not extend
* beyond the bounds of the input.

View File

@ -322,6 +322,9 @@
flex-grow: 1;
// ensure start/end slot content is vertically centered
align-items: center;
width: 100%;
}
@ -641,9 +644,7 @@
/**
* This makes the label sit above the input.
*/
:host(.input-label-placement-stacked) .label-text-wrapper,
:host(.has-focus.input-label-placement-floating) .label-text-wrapper,
:host(.has-value.input-label-placement-floating) .label-text-wrapper {
:host(.label-floating) .label-text-wrapper {
@include transform(translateY(50%), scale(#{$form-control-label-stacked-scale}));
/**
@ -652,3 +653,14 @@
*/
max-width: calc(100% / #{$form-control-label-stacked-scale});
}
// Start/End Slots
// ----------------------------------------------------------------
::slotted([slot="start"]) {
margin-inline-end: $form-control-label-margin;
}
::slotted([slot="end"]) {
margin-inline-start: $form-control-label-margin;
}

View File

@ -26,6 +26,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)
* @slot start - Content to display at the leading edge of the input. (EXPERIMENTAL)
* @slot end - Content to display at the trailing edge of the input. (EXPERIMENTAL)
*/
@Component({
tag: 'ion-input',
@ -369,7 +371,7 @@ export class Input implements ComponentInterface {
const { el } = this;
this.legacyFormController = createLegacyFormController(el);
this.slotMutationController = createSlotMutationController(el, 'label', () => forceUpdate(this));
this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => forceUpdate(this));
this.notchController = createNotchController(
el,
() => this.notchSpacerEl,
@ -697,18 +699,42 @@ export class Input implements ComponentInterface {
}
private renderInput() {
const { disabled, fill, readonly, shape, inputId, labelPlacement } = this;
const { disabled, fill, readonly, shape, inputId, labelPlacement, el, hasFocus } = this;
const mode = getIonMode(this);
const value = this.getValue();
const inItem = hostContext('ion-item', this.el);
const shouldRenderHighlight = mode === 'md' && fill !== 'outline' && !inItem;
const hasValue = this.hasValue();
const hasStartEndSlots = el.querySelector('[slot="start"], [slot="end"]') !== null;
/**
* If the label is stacked, it should always sit above the input.
* For floating labels, the label should move above the input if
* the input has a value, is focused, or has anything in either
* the start or end slot.
*
* If there is content in the start slot, the label would overlap
* it if not forced to float. This is also applied to the end slot
* because with the default or solid fills, the input is not
* vertically centered in the container, but the label is. This
* causes the slots and label to appear vertically offset from each
* other when the label isn't floating above the input. This doesn't
* apply to the outline fill, but this was not accounted for to keep
* things consistent.
*
* TODO(FW-5592): Remove hasStartEndSlots condition
*/
const labelShouldFloat =
labelPlacement === 'stacked' || (labelPlacement === 'floating' && (hasValue || hasFocus || hasStartEndSlots));
return (
<Host
class={createColorClasses(this.color, {
[mode]: true,
'has-value': this.hasValue(),
'has-focus': this.hasFocus,
'has-value': hasValue,
'has-focus': hasFocus,
'label-floating': labelShouldFloat,
[`input-fill-${fill}`]: fill !== undefined,
[`input-shape-${shape}`]: shape !== undefined,
[`input-label-placement-${labelPlacement}`]: true,
@ -717,9 +743,16 @@ export class Input implements ComponentInterface {
'input-disabled': disabled,
})}
>
<label class="input-wrapper">
{/**
* htmlFor is needed so that clicking the label always focuses
* the input. Otherwise, if the start slot has something
* interactable, clicking the label would focus that instead
* since it comes before the input in the DOM.
*/}
<label class="input-wrapper" htmlFor={inputId}>
{this.renderLabelContainer()}
<div class="native-wrapper">
<slot name="start"></slot>
<input
class="native-input"
ref={(input) => (this.nativeInput = input)}
@ -774,6 +807,7 @@ export class Input implements ComponentInterface {
<ion-icon aria-hidden="true" icon={mode === 'ios' ? closeCircle : closeSharp}></ion-icon>
</button>
)}
<slot name="end"></slot>
</div>
{shouldRenderHighlight && <div class="input-highlight"></div>}
</label>

View File

@ -21,7 +21,13 @@
<ion-input label="Email" label-placement="stacked" value="hi@ionic.io"></ion-input><br />
<ion-input label="Email" label-placement="floating"></ion-input> <br />
<ion-input label="Email" label-placement="floating" fill="outline" value="hi@ionic.io"></ion-input> <br />
<ion-input label="Email" label-placement="floating" fill="solid" value="hi@ionic.io"></ion-input>
<ion-input label="Email" label-placement="floating" fill="solid" value="hi@ionic.io"></ion-input><br />
<ion-input label="Email" fill="solid" value="hi@ionic.io">
<ion-icon slot="start" name="lock-closed" aria-hidden="true"></ion-icon>
<ion-button slot="end" aria-label="button">
<ion-icon slot="icon-only" name="lock-closed" aria-hidden="true"></ion-icon>
</ion-button>
</ion-input>
</main>
</body>
</html>

View File

@ -49,53 +49,125 @@
</ion-header>
<ion-content id="content" class="ion-padding">
<h1>Label Slot</h1>
<div class="grid">
<div class="grid-item">
<h2>No Fill / Start</h2>
<h2>No Fill / Start Label</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>
<h2>Solid / Start Label</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>
<h2>Outline / Start Label</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>
<h2>No Fill / Floating Label</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>
<h2>Solid / Floating Label</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>
<h2>Outline / Floating Label</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>
<h1>Start/End Slots</h1>
<div class="grid">
<div class="grid-item">
<h2>No Fill / Start Label</h2>
<ion-input label-placement="start" value="hi@ionic.io" label="Email">
<ion-icon slot="start" name="lock-closed" aria-hidden="true"></ion-icon>
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
</ion-button>
</ion-input>
</div>
<div class="grid-item">
<h2>Outline / Floating / Async</h2>
<h2>Solid / Start Label</h2>
<ion-input label-placement="start" fill="solid" value="hi@ionic.io" label="Email">
<ion-icon slot="start" name="lock-closed" aria-hidden="true"></ion-icon>
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
</ion-button>
</ion-input>
</div>
<div class="grid-item">
<h2>Outline / Start Label</h2>
<ion-input label-placement="start" fill="outline" value="hi@ionic.io" label="Email">
<ion-icon slot="start" name="lock-closed" aria-hidden="true"></ion-icon>
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
</ion-button>
</ion-input>
</div>
<div class="grid-item">
<h2>No Fill / Floating Label</h2>
<ion-input label-placement="floating" value="hi@ionic.io" label="Email">
<ion-icon slot="start" name="lock-closed" aria-hidden="true"></ion-icon>
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
</ion-button>
</ion-input>
</div>
<div class="grid-item">
<h2>Solid / Floating Label</h2>
<ion-input label-placement="floating" fill="solid" value="hi@ionic.io" label="Email">
<ion-icon slot="start" name="lock-closed" aria-hidden="true"></ion-icon>
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
</ion-button>
</ion-input>
</div>
<div class="grid-item">
<h2>Outline / Floating Label</h2>
<ion-input label-placement="floating" fill="outline" value="hi@ionic.io" label="Email">
<ion-icon slot="start" name="lock-closed" aria-hidden="true"></ion-icon>
<ion-button fill="clear" slot="end" aria-label="Show/hide password">
<ion-icon slot="icon-only" name="eye" aria-hidden="true"></ion-icon>
</ion-button>
</ion-input>
</div>
</div>
<h1>Async Slot Content</h1>
<div class="grid">
<div class="grid-item">
<h2>Outline / Async Label</h2>
<ion-input id="solid-async" label-placement="floating" fill="outline" value="hi@ionic.io"></ion-input>
</div>
<div class="grid-item">
<h2>Outline / Async Decorations</h2>
<ion-input id="async-decorations" label-placement="floating" fill="outline" label="Email"></ion-input>
</div>
</div>
<ion-button onclick="addSlot()">Add Slotted Content</ion-button>
@ -106,29 +178,65 @@
<script>
const solidAsync = document.querySelector('#solid-async');
const asyncDecos = document.querySelector('#async-decorations');
const getSlottedContent = () => {
const getSlottedLabel = () => {
return solidAsync.querySelector('[slot="label"]');
};
const getSlottedStartDeco = () => {
return asyncDecos.querySelector('[slot="start"]');
};
const getSlottedEndDeco = () => {
return asyncDecos.querySelector('[slot="end"]');
};
const addSlot = () => {
if (getSlottedContent() === null) {
if (getSlottedLabel() === null) {
const labelEl = document.createElement('div');
labelEl.slot = 'label';
labelEl.innerHTML = 'Email <span class="required">*</span>';
solidAsync.appendChild(labelEl);
}
if (getSlottedStartDeco() === null) {
const startEl = document.createElement('div');
startEl.slot = 'start';
startEl.innerHTML = 'Start';
asyncDecos.insertAdjacentElement('afterbegin', startEl);
}
if (getSlottedEndDeco() === null) {
const endEl = document.createElement('div');
endEl.slot = 'end';
endEl.innerHTML = 'End';
asyncDecos.insertAdjacentElement('beforeend', endEl);
}
};
const removeSlot = () => {
if (getSlottedContent() !== null) {
solidAsync.querySelector('[slot="label"]').remove();
const slottedLabel = getSlottedLabel();
if (slottedLabel !== null) {
slottedLabel.remove();
}
const slottedStartDeco = getSlottedStartDeco();
if (slottedStartDeco !== null) {
slottedStartDeco.remove();
}
const slottedEndDeco = getSlottedEndDeco();
if (slottedEndDeco !== null) {
slottedEndDeco.remove();
}
};
const updateSlot = () => {
const slottedContent = getSlottedContent();
const slottedContent = getSlottedLabel();
if (slottedContent !== null) {
slottedContent.textContent = 'This is my really really really long text';

View File

@ -0,0 +1,68 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
configs().forEach(({ title, screenshot, config }) => {
test.describe(title('input: start and end slots (visual checks)'), () => {
test('should not have visual regressions with a start-positioned label', async ({ page }) => {
await page.setContent(
`
<ion-input label-placement="start" fill="solid" value="100" label="Weight" clear-input="true">
<ion-icon slot="start" name="barbell" aria-hidden="true"></ion-icon>
<ion-label slot="end">lbs</ion-label>
</ion-input>
`,
config
);
const input = page.locator('ion-input');
await expect(input).toHaveScreenshot(screenshot(`input-slots-label-start`));
});
test('should not have visual regressions with a floating label', async ({ page }) => {
await page.setContent(
`
<ion-input label-placement="floating" fill="solid" value="100" label="Weight" clear-input="true">
<ion-icon slot="start" name="barbell" aria-hidden="true"></ion-icon>
<ion-label slot="end">lbs</ion-label>
</ion-input>
`,
config
);
const input = page.locator('ion-input');
await expect(input).toHaveScreenshot(screenshot(`input-slots-label-floating`));
});
});
});
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('input: start and end slots (functionality checks)'), () => {
test('should raise floating label when there is content in the start slot', async ({ page }) => {
await page.setContent(
`
<ion-input label-placement="floating" fill="solid" label="Weight">
<ion-icon slot="start" name="barbell" aria-hidden="true"></ion-icon>
</ion-input>
`,
config
);
const input = page.locator('ion-input');
await expect(input).toHaveClass(/label-floating/);
});
test('should raise floating label when there is content in the end slot', async ({ page }) => {
await page.setContent(
`
<ion-input label-placement="floating" fill="solid" label="Weight">
<ion-icon slot="end" name="barbell" aria-hidden="true"></ion-icon>
</ion-input>
`,
config
);
const input = page.locator('ion-input');
await expect(input).toHaveClass(/label-floating/);
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB