Skip to content

Commit

Permalink
feat(input, textarea, select): add start and end slots (#28583)
Browse files Browse the repository at this point in the history
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: ionic-team/ionic-docs#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>
  • Loading branch information
4 people committed Dec 1, 2023
1 parent b757970 commit 357b8b2
Show file tree
Hide file tree
Showing 97 changed files with 1,070 additions and 129 deletions.
8 changes: 2 additions & 6 deletions core/src/components/input/input.md.outline.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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;
}
4 changes: 1 addition & 3 deletions core/src/components/input/input.md.solid.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 15 additions & 3 deletions core/src/components/input/input.scss
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,9 @@

flex-grow: 1;

// ensure start/end slot content is vertically centered
align-items: center;

width: 100%;
}

Expand Down Expand Up @@ -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}));

/**
Expand All @@ -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;
}
44 changes: 39 additions & 5 deletions core/src/components/input/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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)}
Expand Down Expand Up @@ -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>
Expand Down
8 changes: 7 additions & 1 deletion core/src/components/input/test/a11y/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@ <h1>Input - a11y</h1>
<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>
132 changes: 120 additions & 12 deletions core/src/components/input/test/slot/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -106,29 +178,65 @@ <h2>Outline / Floating / Async</h2>

<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';
Expand Down
Loading

0 comments on commit 357b8b2

Please sign in to comment.