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/);
});
});
});

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -21,11 +21,11 @@
color: #{$text-color-step-350};
}
// Select Native Wrapper
// Select Inner Wrapper
// ----------------------------------------------------------------
:host(.select-label-placement-stacked) .native-wrapper,
:host(.select-label-placement-floating) .native-wrapper {
:host(.select-label-placement-stacked) .select-wrapper-inner,
:host(.select-label-placement-floating) .select-wrapper-inner {
width: calc(100% - $select-ios-icon-size - $select-icon-margin-start);
}

View File

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

View File

@@ -138,11 +138,11 @@
--border-radius: 16px;
}
// Select Native Wrapper
// Select Inner Wrapper
// ----------------------------------------------------------------
:host(.select-label-placement-stacked) .native-wrapper,
:host(.select-label-placement-floating) .native-wrapper {
:host(.select-label-placement-stacked) .select-wrapper-inner,
:host(.select-label-placement-floating) .select-wrapper-inner {
width: calc(100% - $select-md-icon-size - $select-icon-margin-start);
}

View File

@@ -68,10 +68,7 @@
// Select Label
// ----------------------------------------------------------------
:host(.select-fill-solid.select-label-placement-stacked) .label-text-wrapper,
:host(.select-expanded.select-fill-solid.select-label-placement-floating) .label-text-wrapper,
:host(.ion-focused.select-fill-solid.select-label-placement-floating) .label-text-wrapper,
:host(.has-value.select-fill-solid.select-label-placement-floating) .label-text-wrapper {
:host(.label-floating.select-fill-solid) .label-text-wrapper {
/**
* Label text should not extend
* beyond the bounds of the select.

View File

@@ -157,6 +157,13 @@ button {
@include margin(0, 0, 0, $select-icon-margin-start);
position: relative;
/**
* Prevent the icon from shrinking when the label and/or
* selected item text is long enough to fill the rest of
* the container.
*/
flex-shrink: 0;
}
/**
@@ -259,6 +266,25 @@ button {
transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.select-wrapper-inner {
display: flex;
align-items: center;
overflow: hidden;
}
:host(.select-label-placement-stacked) .select-wrapper-inner,
:host(.select-label-placement-floating) .select-wrapper-inner {
/**
* When using a stacked/floating label, the inner wrapper is
* stacked vertically under the label container. This line
* ensures that the inner wrapper fills all the remaining height
* of the component.
*/
flex-grow: 1;
}
// Select Highlight
// ----------------------------------------------------------------
@@ -519,11 +545,23 @@ button {
* The placeholder should be hidden when the label
* is on top of the select. This prevents the label
* from overlapping any placeholder value.
*
* TODO(FW-5592): Remove :not(.label-floating) piece
*/
:host(.select-label-placement-floating) .native-wrapper .select-placeholder {
:host(.select-label-placement-floating:not(.label-floating)) .native-wrapper .select-placeholder {
opacity: 0;
}
/**
* We don't use .label-floating here because that would
* also include the case where the label is floating due
* to content in the start/end slot. We want the opacity
* to remain at the default in this case, since the select
* isn't being actively interacted with.
*
* TODO(FW-5592): Change entire selector to:
* :host(.label-floating.select-label-placement-floating) .native-wrapper .select-placeholder
*/
:host(.select-expanded.select-label-placement-floating) .native-wrapper .select-placeholder,
:host(.ion-focused.select-label-placement-floating) .native-wrapper .select-placeholder,
:host(.has-value.select-label-placement-floating) .native-wrapper .select-placeholder {
@@ -533,10 +571,7 @@ button {
/**
* This makes the label sit above the input.
*/
:host(.select-label-placement-stacked) .label-text-wrapper,
:host(.select-expanded.select-label-placement-floating) .label-text-wrapper,
:host(.ion-focused.select-label-placement-floating) .label-text-wrapper,
:host(.has-value.select-label-placement-floating) .label-text-wrapper {
:host(.label-floating) .label-text-wrapper {
@include transform(translateY(50%), scale(#{$form-control-label-stacked-scale}));
/**
@@ -545,3 +580,23 @@ button {
*/
max-width: calc(100% / #{$form-control-label-stacked-scale});
}
// Start/End Slots
// ----------------------------------------------------------------
::slotted([slot="start"]), ::slotted([slot="end"]) {
/**
* Prevent the slots from shrinking when the label and/or
* selected item text is long enough to fill the rest of
* the container.
*/
flex-shrink: 0;
}
::slotted([slot="start"]) {
margin-inline-end: $form-control-label-margin;
}
::slotted([slot="end"]) {
margin-inline-start: $form-control-label-margin;
}

View File

@@ -33,6 +33,8 @@ import type { SelectChangeEventDetail, SelectInterface, SelectCompareFn } from '
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
*
* @slot label - The label text to associate with the select. Use the `labelPlacement` property to control where the label is placed relative to the select. Use this if you need to render a label with custom HTML.
* @slot start - Content to display at the leading edge of the select.
* @slot end - Content to display at the trailing edge of the select.
*
* @part placeholder - The text displayed in the select when there is no value.
* @part text - The displayed value of the select.
@@ -762,8 +764,22 @@ export class Select implements ComponentInterface {
}
private onClick = (ev: UIEvent) => {
this.setFocus();
this.open(ev);
const target = ev.target as HTMLElement;
const closestSlot = target.closest('[slot="start"], [slot="end"]');
if (target === this.el || closestSlot === null) {
this.setFocus();
this.open(ev);
} else {
/**
* Prevent clicks to the start/end slots from opening the select.
* We ensure the target isn't this element in case the select is slotted
* in, for example, an item. This would prevent the select from ever
* being opened since the element itself has slot="start"/"end".
*/
ev.stopPropagation();
ev.preventDefault();
}
};
private onFocus = () => {
@@ -864,8 +880,31 @@ export class Select implements ComponentInterface {
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;
renderHiddenInput(true, el, name, parseValue(value), disabled);
/**
* If the label is stacked, it should always sit above the select.
* For floating labels, the label should move above the select if
* the select has a value, is open, 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 select 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 || isExpanded || hasStartEndSlots));
return (
<Host
onClick={this.onClick}
@@ -876,7 +915,8 @@ export class Select implements ComponentInterface {
'select-disabled': disabled,
'select-expanded': isExpanded,
'has-expanded-icon': expandedIcon !== undefined,
'has-value': this.hasValue(),
'has-value': hasValue,
'label-floating': labelShouldFloat,
'has-placeholder': placeholder !== undefined,
'ion-focusable': true,
[`select-${rtl}`]: true,
@@ -888,17 +928,23 @@ export class Select implements ComponentInterface {
>
<label class="select-wrapper" id="select-label">
{this.renderLabelContainer()}
<div class="native-wrapper" ref={(el) => (this.nativeWrapperEl = el)} part="container">
{this.renderSelectText()}
<div class="select-wrapper-inner">
<slot name="start"></slot>
<div class="native-wrapper" ref={(el) => (this.nativeWrapperEl = el)} part="container">
{this.renderSelectText()}
{this.renderListbox()}
</div>
<slot name="end"></slot>
{!hasFloatingOrStackedLabel && this.renderSelectIcon()}
{this.renderListbox()}
</div>
{/**
* The icon in a floating/stacked select
* must be centered with the entire select,
* not just the native control. As a result,
* we need to render the icon outside of
* the native wrapper.
* while the start/end slots and native control
* are vertically offset in the default or
* solid fills. As a result, we render the
* icon outside the inner wrapper, which holds
* those components.
*/}
{hasFloatingOrStackedLabel && this.renderSelectIcon()}
{shouldRenderHighlight && <div class="select-highlight"></div>}

View File

@@ -41,6 +41,16 @@
<ion-select-option value="oranges">Oranges</ion-select-option>
</ion-select>
<ion-select label="My Label" label-placement="floating">
<ion-icon slot="start" name="pizza" aria-hidden="true"></ion-icon>
<ion-select-option value="apples">Apples</ion-select-option>
<ion-select-option value="bananas">Bananas</ion-select-option>
<ion-select-option value="oranges">Oranges</ion-select-option>
<ion-button slot="end" aria-label="button">
<ion-icon slot="icon-only" name="lock-closed" aria-hidden="true"></ion-icon>
</ion-button>
</ion-select>
<ion-item>
<ion-select label="My Label" value="apples">
<ion-select-option value="apples">Apples</ion-select-option>

View File

@@ -171,7 +171,7 @@ configs().forEach(({ title, screenshot, config }) => {
test('label should appear on top of the select when the select is expanded', async ({ page }) => {
await page.setContent(
`
<ion-select class="select-expanded" label="Label" label-placement="floating" placeholder="Select a Fruit">
<ion-select class="select-expanded label-floating" label="Label" label-placement="floating" placeholder="Select a Fruit">
<ion-select-option value="apples">Apples</ion-select-option>
</ion-select>
`,

View File

@@ -0,0 +1,251 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Select - Slots</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<style>
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(250px, 1fr));
grid-row-gap: 20px;
grid-column-gap: 20px;
}
h2 {
font-size: 12px;
font-weight: normal;
color: #6f7378;
margin-top: 10px;
}
@media screen and (max-width: 800px) {
.grid {
grid-template-columns: 1fr;
padding: 0;
}
}
</style>
</head>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Select - Slots</ion-title>
</ion-toolbar>
</ion-header>
<ion-content id="content" class="ion-padding">
<div class="grid">
<div class="grid-item">
<h2>No Fill / Start Label / Justify Start</h2>
<ion-select label="Favorite Pizza" placeholder="Select a pizza" label-placement="start" justify="start">
<ion-icon slot="start" name="pizza" aria-hidden="true"></ion-icon>
<ion-select-option value="pepperoni">Pepperoni</ion-select-option>
<ion-select-option value="supreme">Supreme</ion-select-option>
<ion-select-option value="chicken">Chicken</ion-select-option>
<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-select>
</div>
<div class="grid-item">
<h2>Solid / Start Label / Justify Start</h2>
<ion-select
label="Favorite Pizza"
placeholder="Select a pizza"
label-placement="start"
justify="start"
fill="solid"
>
<ion-icon slot="start" name="pizza" aria-hidden="true"></ion-icon>
<ion-select-option value="pepperoni">Pepperoni</ion-select-option>
<ion-select-option value="supreme">Supreme</ion-select-option>
<ion-select-option value="chicken">Chicken</ion-select-option>
<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-select>
</div>
<div class="grid-item">
<h2>Outline / Start Label / Justify Start</h2>
<ion-select
label="Favorite Pizza"
placeholder="Select a pizza"
label-placement="start"
justify="start"
fill="outline"
>
<ion-icon slot="start" name="pizza" aria-hidden="true"></ion-icon>
<ion-select-option value="pepperoni">Pepperoni</ion-select-option>
<ion-select-option value="supreme">Supreme</ion-select-option>
<ion-select-option value="chicken">Chicken</ion-select-option>
<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-select>
</div>
<div class="grid-item">
<h2>No Fill / Start Label / Justify End</h2>
<ion-select label="Favorite Pizza" placeholder="Select a pizza" label-placement="start" justify="end">
<ion-icon slot="start" name="pizza" aria-hidden="true"></ion-icon>
<ion-select-option value="pepperoni">Pepperoni</ion-select-option>
<ion-select-option value="supreme">Supreme</ion-select-option>
<ion-select-option value="chicken">Chicken</ion-select-option>
<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-select>
</div>
<div class="grid-item">
<h2>Solid / Start Label / Justify End</h2>
<ion-select
label="Favorite Pizza"
placeholder="Select a pizza"
label-placement="start"
justify="end"
fill="solid"
>
<ion-icon slot="start" name="pizza" aria-hidden="true"></ion-icon>
<ion-select-option value="pepperoni">Pepperoni</ion-select-option>
<ion-select-option value="supreme">Supreme</ion-select-option>
<ion-select-option value="chicken">Chicken</ion-select-option>
<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-select>
</div>
<div class="grid-item">
<h2>Outline / Start Label / Justify End</h2>
<ion-select
label="Favorite Pizza"
placeholder="Select a pizza"
label-placement="start"
justify="end"
fill="outline"
>
<ion-icon slot="start" name="pizza" aria-hidden="true"></ion-icon>
<ion-select-option value="pepperoni">Pepperoni</ion-select-option>
<ion-select-option value="supreme">Supreme</ion-select-option>
<ion-select-option value="chicken">Chicken</ion-select-option>
<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-select>
</div>
<div class="grid-item">
<h2>No Fill / Start Label / Justify Space Between</h2>
<ion-select
label="Favorite Pizza"
placeholder="Select a pizza"
label-placement="start"
justify="space-between"
>
<ion-icon slot="start" name="pizza" aria-hidden="true"></ion-icon>
<ion-select-option value="pepperoni">Pepperoni</ion-select-option>
<ion-select-option value="supreme">Supreme</ion-select-option>
<ion-select-option value="chicken">Chicken</ion-select-option>
<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-select>
</div>
<div class="grid-item">
<h2>Solid / Start Label / Justify Space Between</h2>
<ion-select
label="Favorite Pizza"
placeholder="Select a pizza"
label-placement="start"
justify="space-between"
fill="solid"
>
<ion-icon slot="start" name="pizza" aria-hidden="true"></ion-icon>
<ion-select-option value="pepperoni">Pepperoni</ion-select-option>
<ion-select-option value="supreme">Supreme</ion-select-option>
<ion-select-option value="chicken">Chicken</ion-select-option>
<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-select>
</div>
<div class="grid-item">
<h2>Outline / Start Label / Justify Space Between</h2>
<ion-select
label="Favorite Pizza"
placeholder="Select a pizza"
label-placement="start"
justify="space-between"
fill="outline"
>
<ion-icon slot="start" name="pizza" aria-hidden="true"></ion-icon>
<ion-select-option value="pepperoni">Pepperoni</ion-select-option>
<ion-select-option value="supreme">Supreme</ion-select-option>
<ion-select-option value="chicken">Chicken</ion-select-option>
<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-select>
</div>
<div class="grid-item">
<h2>No Fill / Floating Label</h2>
<ion-select label="Favorite Pizza" placeholder="Select a pizza" label-placement="floating">
<ion-icon slot="start" name="pizza" aria-hidden="true"></ion-icon>
<ion-select-option value="pepperoni">Pepperoni</ion-select-option>
<ion-select-option value="supreme">Supreme</ion-select-option>
<ion-select-option value="chicken">Chicken</ion-select-option>
<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-select>
</div>
<div class="grid-item">
<h2>Solid / Floating Label</h2>
<ion-select label="Favorite Pizza" placeholder="Select a pizza" label-placement="floating" fill="solid">
<ion-icon slot="start" name="pizza" aria-hidden="true"></ion-icon>
<ion-select-option value="pepperoni">Pepperoni</ion-select-option>
<ion-select-option value="supreme">Supreme</ion-select-option>
<ion-select-option value="chicken">Chicken</ion-select-option>
<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-select>
</div>
<div class="grid-item">
<h2>Outline / Floating Label</h2>
<ion-select label="Favorite Pizza" placeholder="Select a pizza" label-placement="floating" fill="outline">
<ion-icon slot="start" name="pizza" aria-hidden="true"></ion-icon>
<ion-select-option value="pepperoni">Pepperoni</ion-select-option>
<ion-select-option value="supreme">Supreme</ion-select-option>
<ion-select-option value="chicken">Chicken</ion-select-option>
<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-select>
</div>
</div>
</ion-content>
</ion-app>
</body>
</html>

View File

@@ -0,0 +1,90 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
configs().forEach(({ title, screenshot, config }) => {
test.describe(title('select: start and end slots (visual checks)'), () => {
test('should not have visual regressions with a start-positioned label', async ({ page }) => {
await page.setContent(
`
<ion-select label-placement="start" fill="solid" placeholder="Select weight" label="Weight">
<ion-icon slot="start" name="barbell" aria-hidden="true"></ion-icon>
<ion-label slot="end">lbs</ion-label>
</ion-select>
`,
config
);
const select = page.locator('ion-select');
await expect(select).toHaveScreenshot(screenshot(`select-slots-label-start`));
});
test('should not have visual regressions with a floating label', async ({ page }) => {
await page.setContent(
`
<ion-select label-placement="floating" fill="solid" placeholder="Select weight" label="Weight">
<ion-icon slot="start" name="barbell" aria-hidden="true"></ion-icon>
<ion-label slot="end">lbs</ion-label>
</ion-select>
`,
config
);
const select = page.locator('ion-select');
await expect(select).toHaveScreenshot(screenshot(`select-slots-label-floating`));
});
});
});
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('select: 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-select label-placement="floating" fill="solid" label="Weight">
<ion-icon slot="start" name="barbell" aria-hidden="true"></ion-icon>
</ion-select>
`,
config
);
const select = page.locator('ion-select');
await expect(select).toHaveClass(/label-floating/);
});
test('should raise floating label when there is content in the end slot', async ({ page }) => {
await page.setContent(
`
<ion-select label-placement="floating" fill="solid" label="Weight">
<ion-icon slot="end" name="barbell" aria-hidden="true"></ion-icon>
</ion-select>
`,
config
);
const select = page.locator('ion-select');
await expect(select).toHaveClass(/label-floating/);
});
test('should not open select when slotted buttons are clicked', async ({ page }) => {
await page.setContent(
`
<ion-select label="Favorite Pizza" placeholder="Select a pizza">
<ion-select-option value="pepperoni">Pepperoni</ion-select-option>
<ion-select-option value="supreme">Supreme</ion-select-option>
<ion-select-option value="chicken">Chicken</ion-select-option>
<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-select>
`,
config
);
await page.click('ion-select ion-button[slot="end"]');
await page.waitForChanges();
const select = page.locator('ion-select');
await expect(select).not.toHaveClass(/select-expanded/);
});
});
});

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

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>

View File

@@ -6,18 +6,20 @@ import { raf } from '@utils/helpers';
* This is not needed for components using native slots in the Shadow DOM.
* @internal
* @param el The host element to observe
* @param slotName mutationCallback will fire when nodes on this slot change
* @param slotName mutationCallback will fire when nodes on these slot(s) change
* @param mutationCallback The callback to fire whenever the slotted content changes
*/
export const createSlotMutationController = (
el: HTMLElement,
slotName: string,
slotName: string | string[],
mutationCallback: () => void
): SlotMutationController => {
let hostMutationObserver: MutationObserver | undefined;
let slottedContentMutationObserver: MutationObserver | undefined;
if (win !== undefined && 'MutationObserver' in win) {
const slots = Array.isArray(slotName) ? slotName : [slotName];
hostMutationObserver = new MutationObserver((entries) => {
for (const entry of entries) {
for (const node of entry.addedNodes) {
@@ -25,7 +27,7 @@ export const createSlotMutationController = (
* Check to see if the added node
* is our slotted content.
*/
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).slot === slotName) {
if (node.nodeType === Node.ELEMENT_NODE && slots.includes((node as HTMLElement).slot)) {
/**
* If so, we want to watch the slotted
* content itself for changes. This lets us