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

@ -21,7 +21,13 @@
<ion-textarea label="Email" label-placement="stacked" value="hi@ionic.io"></ion-textarea>
<ion-textarea label="Email" label-placement="floating"></ion-textarea>
<ion-textarea label="Email" label-placement="floating" fill="outline" value="hi@ionic.io"></ion-textarea> <br />
<ion-textarea label="Email" label-placement="floating" fill="solid" value="hi@ionic.io"></ion-textarea>
<ion-textarea label="Email" label-placement="floating" fill="solid" value="hi@ionic.io"></ion-textarea><br />
<ion-textarea label="Email" label-placement="floating" 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-textarea>
</main>
</body>
</html>

View File

@ -49,53 +49,135 @@
</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-textarea label-placement="start" value="hi@ionic.io">
<div slot="label">Email <span class="required">*</span></div>
</ion-textarea>
</div>
<div class="grid-item">
<h2>Solid / Start</h2>
<h2>Solid / Start Label</h2>
<ion-textarea label-placement="start" fill="solid" value="hi@ionic.io">
<div slot="label">Email <span class="required">*</span></div>
</ion-textarea>
</div>
<div class="grid-item">
<h2>Outline / Start</h2>
<h2>Outline / Start Label</h2>
<ion-textarea label-placement="start" fill="outline" value="hi@ionic.io">
<div slot="label">Email <span class="required">*</span></div>
</ion-textarea>
</div>
<div class="grid-item">
<h2>No Fill / Floating</h2>
<h2>No Fill / Floating Label</h2>
<ion-textarea label-placement="floating" value="hi@ionic.io">
<div slot="label">Email <span class="required">*</span></div>
</ion-textarea>
</div>
<div class="grid-item">
<h2>Solid / Floating</h2>
<h2>Solid / Floating Label</h2>
<ion-textarea label-placement="floating" fill="solid" value="hi@ionic.io">
<div slot="label">Email <span class="required">*</span></div>
</ion-textarea>
</div>
<div class="grid-item">
<h2>Outline / Floating</h2>
<h2>Outline / Floating Label</h2>
<ion-textarea label-placement="floating" fill="outline" value="hi@ionic.io">
<div slot="label">Email <span class="required">*</span></div>
</ion-textarea>
</div>
</div>
<h1>Start/End Slots</h1>
<div class="grid">
<div class="grid-item">
<h2>No Fill / Start Label</h2>
<ion-textarea 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-textarea>
</div>
<div class="grid-item">
<h2>Outline / Floating / Async</h2>
<h2>Solid / Start Label</h2>
<ion-textarea 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-textarea>
</div>
<div class="grid-item">
<h2>Outline / Start Label</h2>
<ion-textarea 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-textarea>
</div>
<div class="grid-item">
<h2>No Fill / Floating Label</h2>
<ion-textarea 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-textarea>
</div>
<div class="grid-item">
<h2>Solid / Floating Label</h2>
<ion-textarea 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-textarea>
</div>
<div class="grid-item">
<h2>Outline / Floating Label</h2>
<ion-textarea 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-textarea>
</div>
<div class="grid-item">
<h2>Outline / Autogrow</h2>
<ion-textarea label-placement="start" fill="outline" label="Email" auto-grow="true" value="hi@ionic.io">
<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-textarea>
</div>
</div>
<h1>Async Slot Content</h1>
<div class="grid">
<div class="grid-item">
<h2>Outline / Async Label</h2>
<ion-textarea id="solid-async" label-placement="floating" fill="outline" value="hi@ionic.io"></ion-textarea>
</div>
<div class="grid-item">
<h2>Outline / Async Decorations</h2>
<ion-textarea id="async-decorations" label-placement="floating" fill="outline" label="Email"></ion-textarea>
</div>
</div>
<ion-button onclick="addSlot()">Add Slotted Content</ion-button>
@ -106,29 +188,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 = 'Comments <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('textarea: start and end slots (visual checks)'), () => {
test('should not have visual regressions with a start-positioned label', async ({ page }) => {
await page.setContent(
`
<ion-textarea label-placement="start" fill="solid" value="100" label="Weight">
<ion-icon slot="start" name="barbell" aria-hidden="true"></ion-icon>
<ion-label slot="end">lbs</ion-label>
</ion-textarea>
`,
config
);
const textarea = page.locator('ion-textarea');
await expect(textarea).toHaveScreenshot(screenshot(`textarea-slots-label-start`));
});
test('should not have visual regressions with a floating label', async ({ page }) => {
await page.setContent(
`
<ion-textarea label-placement="floating" fill="solid" value="100" label="Weight">
<ion-icon slot="start" name="barbell" aria-hidden="true"></ion-icon>
<ion-label slot="end">lbs</ion-label>
</ion-textarea>
`,
config
);
const textarea = page.locator('ion-textarea');
await expect(textarea).toHaveScreenshot(screenshot(`textarea-slots-label-floating`));
});
});
});
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('textarea: 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-textarea label-placement="floating" fill="solid" label="Weight">
<ion-icon slot="start" name="barbell" aria-hidden="true"></ion-icon>
</ion-textarea>
`,
config
);
const textarea = page.locator('ion-textarea');
await expect(textarea).toHaveClass(/label-floating/);
});
test('should raise floating label when there is content in the end slot', async ({ page }) => {
await page.setContent(
`
<ion-textarea label-placement="floating" fill="solid" label="Weight">
<ion-icon slot="end" name="barbell" aria-hidden="true"></ion-icon>
</ion-textarea>
`,
config
);
const textarea = page.locator('ion-textarea');
await expect(textarea).toHaveClass(/label-floating/);
});
});
});

View File

@ -89,9 +89,7 @@
/**
* This makes the label sit above the textarea.
*/
:host(.has-focus.textarea-fill-outline.textarea-label-placement-floating) .label-text-wrapper,
:host(.has-value.textarea-fill-outline.textarea-label-placement-floating) .label-text-wrapper,
:host(.textarea-fill-outline.textarea-label-placement-stacked) .label-text-wrapper {
:host(.label-floating.textarea-fill-outline) .label-text-wrapper {
@include transform(translateY(-32%), scale(#{$form-control-label-stacked-scale}));
@include margin(0);
@ -116,6 +114,13 @@
@include margin(12px, 0px, 0px, 0px);
}
:host(.textarea-fill-outline.textarea-label-placement-stacked) ::slotted([slot="start"]),
:host(.textarea-fill-outline.textarea-label-placement-stacked) ::slotted([slot="end"]),
:host(.textarea-fill-outline.textarea-label-placement-floating) ::slotted([slot="start"]),
:host(.textarea-fill-outline.textarea-label-placement-floating) ::slotted([slot="end"]) {
margin-top: 12px;
}
// Textarea Fill: Outline Outline Container
// ----------------------------------------------------------------
@ -220,8 +225,6 @@
* the floating/stacked label. We simulate this "cut out"
* by removing the top border from the notch fragment.
*/
:host(.has-focus.textarea-fill-outline.textarea-label-placement-floating) .textarea-outline-notch,
:host(.has-value.textarea-fill-outline.textarea-label-placement-floating) .textarea-outline-notch,
:host(.textarea-fill-outline.textarea-label-placement-stacked) .textarea-outline-notch {
:host(.label-floating.textarea-fill-outline) .textarea-outline-notch {
border-top: none;
}

View File

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

View File

@ -330,12 +330,8 @@
// ----------------------------------------------------------------
.native-wrapper {
display: flex;
position: relative;
flex-grow: 1;
width: 100%;
height: 100%;
@ -414,6 +410,13 @@
word-break: break-word;
}
.textarea-wrapper-inner {
display: flex;
width: 100%;
min-height: inherit;
}
// Textarea Highlight
// ----------------------------------------------------------------
@ -688,6 +691,13 @@
@include margin(8px, 0px, 0px, 0px);
}
:host(.textarea-label-placement-stacked) ::slotted([slot="start"]),
:host(.textarea-label-placement-stacked) ::slotted([slot="end"]),
:host(.textarea-label-placement-floating) ::slotted([slot="start"]),
:host(.textarea-label-placement-floating) ::slotted([slot="end"]) {
margin-top: 8px;
}
/**
* This makes the label sit over the textarea
* when the textarea is blurred and has no value.
@ -713,9 +723,7 @@
/**
* This makes the label sit above the textarea.
*/
:host(.textarea-label-placement-stacked) .label-text-wrapper,
:host(.has-focus.textarea-label-placement-floating) .label-text-wrapper,
:host(.has-value.textarea-label-placement-floating) .label-text-wrapper {
:host(.label-floating) .label-text-wrapper {
@include transform(translateY(50%), scale(#{$form-control-label-stacked-scale}));
/**
@ -724,3 +732,29 @@
*/
max-width: calc(100% / #{$form-control-label-stacked-scale});
}
// Start/End Slots
// ----------------------------------------------------------------
.start-slot-wrapper, .end-slot-wrapper {
@include padding(var(--padding-top), 0, var(--padding-bottom), 0);
display: flex;
flex-shrink: 0;
align-self: start;
}
::slotted([slot="start"]),
::slotted([slot="end"]) {
margin-top: 0; // ensure slot content is vertically aligned with label
}
::slotted([slot="start"]) {
margin-inline-end: $form-control-label-margin;
}
::slotted([slot="end"]) {
margin-inline-start: $form-control-label-margin;
}

View File

@ -38,6 +38,8 @@ import type { TextareaChangeEventDetail, TextareaInputEventDetail } from './text
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
*
* @slot label - The label text to associate with the textarea. Use the `labelPlacement` property to control where the label is placed relative to the textarea. Use this if you need to render a label with custom HTML. (EXPERIMENTAL)
* @slot start - Content to display at the leading edge of the textarea. (EXPERIMENTAL)
* @slot end - Content to display at the trailing edge of the textarea. (EXPERIMENTAL)
*/
@Component({
tag: 'ion-textarea',
@ -316,7 +318,7 @@ export class Textarea implements ComponentInterface {
connectedCallback() {
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,
@ -705,55 +707,100 @@ Developers can use the "legacy" property to continue using the legacy form marku
}
private renderTextarea() {
const { inputId, disabled, fill, shape, labelPlacement } = this;
const { inputId, disabled, fill, shape, 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 textarea.
* For floating labels, the label should move above the textarea if
* the textarea 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 textarea 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,
[`textarea-fill-${fill}`]: fill !== undefined,
[`textarea-shape-${shape}`]: shape !== undefined,
[`textarea-label-placement-${labelPlacement}`]: true,
'textarea-disabled': disabled,
})}
>
<label class="textarea-wrapper">
{/**
* htmlFor is needed so that clicking the label always focuses
* the textarea. Otherwise, if the start slot has something
* interactable, clicking the label would focus that instead
* since it comes before the textarea in the DOM.
*/}
<label class="textarea-wrapper" htmlFor={inputId}>
{this.renderLabelContainer()}
<div class="native-wrapper" ref={(el) => (this.textareaWrapper = el)}>
<textarea
class="native-textarea"
ref={(el) => (this.nativeInput = el)}
id={inputId}
disabled={disabled}
autoCapitalize={this.autocapitalize}
autoFocus={this.autofocus}
enterKeyHint={this.enterkeyhint}
inputMode={this.inputmode}
minLength={this.minlength}
maxLength={this.maxlength}
name={this.name}
placeholder={this.placeholder || ''}
readOnly={this.readonly}
required={this.required}
spellcheck={this.spellcheck}
cols={this.cols}
rows={this.rows}
wrap={this.wrap}
onInput={this.onInput}
onChange={this.onChange}
onBlur={this.onBlur}
onFocus={this.onFocus}
onKeyDown={this.onKeyDown}
{...this.inheritedAttributes}
>
{value}
</textarea>
<div class="textarea-wrapper-inner">
{/**
* Some elements have their own padding styles which may
* interfere with slot content alignment (such as icon-
* only buttons setting --padding-top=0). To avoid this,
* we wrap both the start and end slots in separate
* elements and apply our padding styles to that instead.
*/}
<div class="start-slot-wrapper">
<slot name="start"></slot>
</div>
<div class="native-wrapper" ref={(el) => (this.textareaWrapper = el)}>
<textarea
class="native-textarea"
ref={(el) => (this.nativeInput = el)}
id={inputId}
disabled={disabled}
autoCapitalize={this.autocapitalize}
autoFocus={this.autofocus}
enterKeyHint={this.enterkeyhint}
inputMode={this.inputmode}
minLength={this.minlength}
maxLength={this.maxlength}
name={this.name}
placeholder={this.placeholder || ''}
readOnly={this.readonly}
required={this.required}
spellcheck={this.spellcheck}
cols={this.cols}
rows={this.rows}
wrap={this.wrap}
onInput={this.onInput}
onChange={this.onChange}
onBlur={this.onBlur}
onFocus={this.onFocus}
onKeyDown={this.onKeyDown}
{...this.inheritedAttributes}
>
{value}
</textarea>
</div>
<div class="end-slot-wrapper">
<slot name="end"></slot>
</div>
</div>
{shouldRenderHighlight && <div class="textarea-highlight"></div>}
</label>