feat(input): add outline appearance for stacked label to ionic theme (#29268)

Issue number: Internal

---------

<!-- 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 new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

All changes are specific to the `ionic` theme.
- Styles added for `fill="outline"` plus `labelPlacement="stacked"`.
- Markup rearranged slightly to ensure label sits above outline while
still being clickable to focus the input. See code comments for details.
- The default `labelPlacement` is now `"stacked"`.
- Values for `labelPlacement` besides `"stacked"` and `"floating"`
cannot be used.

Note that per the ticket, I did not account for any other scope,
including styles for helper text, `labelPlacement="floating"`,
`shape="round"`, etc. This means that some states will look broken for
now, and will be addressed in future tickets.

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/.github/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

---------

Co-authored-by: ionitron <hi@ionicframework.com>
Co-authored-by: Maria Hutt <thetaPC@users.noreply.github.com>
This commit is contained in:
Amanda Johnston
2024-04-09 10:44:13 -05:00
committed by GitHub
parent ca59d6c297
commit bb516f0da7
45 changed files with 235 additions and 12 deletions

View File

@ -610,7 +610,7 @@ ion-input,prop,fill,"outline" | "solid" | undefined,undefined,false,false
ion-input,prop,helperText,string | undefined,undefined,false,false
ion-input,prop,inputmode,"decimal" | "email" | "none" | "numeric" | "search" | "tel" | "text" | "url" | undefined,undefined,false,false
ion-input,prop,label,string | undefined,undefined,false,false
ion-input,prop,labelPlacement,"end" | "fixed" | "floating" | "stacked" | "start",'start',false,false
ion-input,prop,labelPlacement,"end" | "fixed" | "floating" | "stacked" | "start" | undefined,undefined,false,false
ion-input,prop,max,number | string | undefined,undefined,false,false
ion-input,prop,maxlength,number | undefined,undefined,false,false
ion-input,prop,min,number | string | undefined,undefined,false,false

View File

@ -1395,9 +1395,9 @@ export namespace Components {
*/
"label"?: string;
/**
* Where to place the label relative to the input. `"start"`: The label will appear to the left of the input in LTR and to the right in RTL. `"end"`: The label will appear to the right of the input in LTR and to the left in RTL. `"floating"`: The label will appear smaller and above the input when the input is focused or it has a value. Otherwise it will appear on top of the input. `"stacked"`: The label will appear smaller and above the input regardless even when the input is blurred or has no value. `"fixed"`: The label has the same behavior as `"start"` except it also has a fixed width. Long text will be truncated with ellipses ("...").
* Where to place the label relative to the input. `"start"`: The label will appear to the left of the input in LTR and to the right in RTL. `"end"`: The label will appear to the right of the input in LTR and to the left in RTL. `"floating"`: The label will appear smaller and above the input when the input is focused or it has a value. Otherwise it will appear on top of the input. `"stacked"`: The label will appear smaller and above the input regardless even when the input is blurred or has no value. `"fixed"`: The label has the same behavior as `"start"` except it also has a fixed width. Long text will be truncated with ellipses ("..."). Defaults to "stacked" for the ionic theme, or "start" for all other themes. In the ionic theme, only the values "stacked" and "floating" are supported.
*/
"labelPlacement": 'start' | 'end' | 'floating' | 'stacked' | 'fixed';
"labelPlacement"?: 'start' | 'end' | 'floating' | 'stacked' | 'fixed';
/**
* The maximum value, which must not be less than its minimum (min attribute) value.
*/
@ -6631,7 +6631,7 @@ declare namespace LocalJSX {
*/
"label"?: string;
/**
* Where to place the label relative to the input. `"start"`: The label will appear to the left of the input in LTR and to the right in RTL. `"end"`: The label will appear to the right of the input in LTR and to the left in RTL. `"floating"`: The label will appear smaller and above the input when the input is focused or it has a value. Otherwise it will appear on top of the input. `"stacked"`: The label will appear smaller and above the input regardless even when the input is blurred or has no value. `"fixed"`: The label has the same behavior as `"start"` except it also has a fixed width. Long text will be truncated with ellipses ("...").
* Where to place the label relative to the input. `"start"`: The label will appear to the left of the input in LTR and to the right in RTL. `"end"`: The label will appear to the right of the input in LTR and to the left in RTL. `"floating"`: The label will appear smaller and above the input when the input is focused or it has a value. Otherwise it will appear on top of the input. `"stacked"`: The label will appear smaller and above the input regardless even when the input is blurred or has no value. `"fixed"`: The label has the same behavior as `"start"` except it also has a fixed width. Long text will be truncated with ellipses ("..."). Defaults to "stacked" for the ionic theme, or "start" for all other themes. In the ionic theme, only the values "stacked" and "floating" are supported.
*/
"labelPlacement"?: 'start' | 'end' | 'floating' | 'stacked' | 'fixed';
/**

View File

@ -0,0 +1,111 @@
@import "./input.vars";
@import "../../foundations/ionic.vars";
// Input Fill: Outline (Ionic Theme)
// ----------------------------------------------------------------
:host(.input-fill-outline) {
--border-radius: #{$ionic-border-radius-rounded-small};
--padding-start: 12px;
--padding-end: 12px;
--placeholder-color: #{$ionic-color-neutral-600};
--placeholder-opacity: 1;
}
/**
* The bottom content should never have
* a border with the outline style.
*/
:host(.input-fill-outline) .input-bottom {
border-top: none;
}
:host(.input-fill-outline) .input-wrapper {
/**
* For the ionic theme, the padding needs to sit on the
* native wrapper instead, so that it sits within the
* outline container but does not affect the label text.
* For the ionic theme, the horizontal padding needs to
* sit on the native wrapper instead, so that
*/
@include padding(0);
/**
* Outline inputs do not have a bottom border.
* Instead, they have a border that wraps the
* input + label.
*/
border-bottom: none;
}
:host(.input-fill-outline.input-label-placement-stacked) .label-text-wrapper {
@include transform-origin(start, top);
/**
* Label text should not extend
* beyond the bounds of the input.
*/
max-width: calc(100% - var(--padding-start) - var(--padding-end));
}
:host(.input-fill-outline) .label-text-wrapper {
/**
* The label should appear on top of an outline
* container that overlaps it so it is always clickable.
*/
position: relative;
color: #{$ionic-color-neutral-700};
}
:host(.input-fill-outline) .native-wrapper {
@include padding(var(--padding-top), var(--padding-end), var(--padding-bottom), var(--padding-start));
min-height: 40px;
}
// Input Fill: Outline, Outline Container
// ----------------------------------------------------------------
:host(.input-fill-outline) .input-outline {
@include position(0, 0, 0, 0);
@include border-radius(var(--border-radius));
position: absolute;
width: 100%;
height: 100%;
pointer-events: none;
border: var(--border-width) var(--border-style) var(--border-color);
}
// Input Fill: Outline, Label Placement: Stacked
// ----------------------------------------------------------------
// This makes the label sit above the input.
:host(.label-floating.input-fill-outline.input-label-placement-stacked:not(.input-shape-round)) .label-text-wrapper {
@include transform(translateY(0), scale(#{$form-control-label-stacked-scale}));
@include margin(0);
/**
* Label text should not extend
* beyond the bounds of the input.
*/
max-width: calc((100% - var(--padding-start) - var(--padding-end)) / #{$form-control-label-stacked-scale});
}
// Start/End Slots
// ----------------------------------------------------------------
:host(.input-fill-outline) ::slotted([slot="start"]) {
margin-inline-end: 8px;
}
:host(.input-fill-outline) ::slotted([slot="end"]) {
margin-inline-start: 8px;
}

View File

@ -1,9 +1,14 @@
@import "./input";
@import "./input.ionic.vars";
@import "./input.ionic.outline.scss";
// Ionic Input
// --------------------------------------------------
:host {
--border-width: #{$ionic-border-size-small};
--border-color: #{$ionic-color-neutral-300};
// TODO(FW-6113): Verify the ionic design token is correct once it's available and remove the hardcoded value.
--highlight-color-invalid: var(--ionic-color-error-600, #970606);
}
@ -11,7 +16,7 @@
// Ionic Input Sizes
// --------------------------------------------------
:host(.input-size-large) {
:host(.input-size-large) .native-wrapper {
min-height: 48px;
}

View File

@ -1,4 +1,3 @@
// Ionic Input
// --------------------------------------------------

View File

@ -181,8 +181,12 @@ export class Input implements ComponentInterface {
* `"floating"`: The label will appear smaller and above the input when the input is focused or it has a value. Otherwise it will appear on top of the input.
* `"stacked"`: The label will appear smaller and above the input regardless even when the input is blurred or has no value.
* `"fixed"`: The label has the same behavior as `"start"` except it also has a fixed width. Long text will be truncated with ellipses ("...").
*
* Defaults to "stacked" for the ionic theme, or "start" for all other themes.
*
* In the ionic theme, only the values "stacked" and "floating" are supported.
*/
@Prop() labelPlacement: 'start' | 'end' | 'floating' | 'stacked' | 'fixed' = 'start';
@Prop({ mutable: true }) labelPlacement?: 'start' | 'end' | 'floating' | 'stacked' | 'fixed';
/**
* The maximum value, which must not be less than its minimum (min attribute) value.
@ -343,6 +347,10 @@ export class Input implements ComponentInterface {
...inheritAriaAttributes(this.el),
...inheritAttributes(this.el, ['tabindex', 'title', 'data-form-type']),
};
if (this.labelPlacement === undefined) {
this.labelPlacement = getIonTheme(this) === 'ionic' ? ionicThemeDefaultLabelPlacement : 'start';
}
}
connectedCallback() {
@ -471,6 +479,21 @@ export class Input implements ComponentInterface {
return typeof this.value === 'number' ? this.value.toString() : (this.value || '').toString();
}
private getLabelPlacement() {
const theme = getIonTheme(this);
const { el, labelPlacement } = this;
if (theme === 'ionic' && labelPlacement !== 'stacked' && labelPlacement !== 'floating') {
printIonWarning(
`The "${labelPlacement}" label placement is not supported in the ${theme} theme. The default value of "${ionicThemeDefaultLabelPlacement}" will be used instead.`,
el
);
return ionicThemeDefaultLabelPlacement;
}
return labelPlacement;
}
private getSize() {
const theme = getIonTheme(this);
const { size } = this;
@ -663,9 +686,9 @@ export class Input implements ComponentInterface {
*/
private renderLabelContainer() {
const theme = getIonTheme(this);
const hasOutlineFill = theme === 'md' && this.fill === 'outline';
const hasOutlineFill = this.fill === 'outline';
if (hasOutlineFill) {
if (hasOutlineFill && theme === 'md') {
/**
* The outline fill has a special outline
* that appears around the input and the label.
@ -693,19 +716,21 @@ export class Input implements ComponentInterface {
}
/**
* If not using the outline style,
* we can render just the label.
* If not using the outline style, OR if using the
* ionic theme, just render the label. For the ionic
* theme, the outline will be rendered elsewhere.
*/
return this.renderLabel();
}
render() {
const { disabled, fill, readonly, shape, inputId, labelPlacement, el, hasFocus } = this;
const { disabled, fill, readonly, shape, inputId, el, hasFocus } = this;
const theme = getIonTheme(this);
const value = this.getValue();
const size = this.getSize();
const inItem = hostContext('ion-item', this.el);
const shouldRenderHighlight = theme === 'md' && fill !== 'outline' && !inItem;
const labelPlacement = this.getLabelPlacement();
const hasValue = this.hasValue();
const hasStartEndSlots = el.querySelector('[slot="start"], [slot="end"]') !== null;
@ -755,6 +780,18 @@ export class Input implements ComponentInterface {
<label class="input-wrapper" htmlFor={inputId}>
{this.renderLabelContainer()}
<div class="native-wrapper">
{
/**
* For the ionic theme, we render the outline container here
* instead of higher up, so it can be positioned relative to
* the native wrapper instead of the <label> element or the
* entire component. This allows the label text to be positioned
* above the outline, while staying within the bounds of the
* <label> element, ensuring that clicking the label text
* focuses the input.
*/
theme === 'ionic' && fill === 'outline' && <div class="input-outline"></div>
}
<slot name="start"></slot>
<input
class="native-input"
@ -819,3 +856,4 @@ export class Input implements ComponentInterface {
}
let inputIds = 0;
const ionicThemeDefaultLabelPlacement = 'stacked';

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 911 B

After

Width:  |  Height:  |  Size: 923 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 875 B

After

Width:  |  Height:  |  Size: 887 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -248,3 +248,24 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
});
});
});
configs({ modes: ['ionic-md'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('input: ionic theme fill'), () => {
test('should not have visual regressions with outline fill and stacked label placement', async ({ page }) => {
await page.setContent(
`
<ion-input
fill="outline"
label="Email"
label-placement="stacked"
placeholder="example@ionic.io"
></ion-input>
`,
config
);
const input = page.locator('ion-input');
await expect(input).toHaveScreenshot(screenshot(`input-fill-outline-label-stacked`));
});
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -155,6 +155,36 @@
</ion-button>
</ion-input>
</div>
<div class="grid-item">
<h2>No Fill / Stacked Label</h2>
<ion-input label-placement="stacked" 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 / Stacked Label</h2>
<ion-input label-placement="stacked" 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 / Stacked Label</h2>
<ion-input label-placement="stacked" 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>

View File

@ -47,6 +47,25 @@ configs().forEach(({ title, screenshot, config }) => {
});
});
configs({ modes: ['ionic-md'] }).forEach(({ title, config, screenshot }) => {
test.describe(title('input: start and end slots (visual checks for ionic theme)'), () => {
test('should not have visual regressions with a stacked label and outline fill', async ({ page }) => {
await page.setContent(
`
<ion-input label-placement="stacked" fill="outline" value="hi@ionic.io" label="Email">
<ion-icon slot="start" name="lock-closed" aria-hidden="true"></ion-icon>
<ion-icon slot="end" name="lock-closed" aria-hidden="true"></ion-icon>
</ion-input>
`,
config
);
const input = page.locator('ion-input');
await expect(input).toHaveScreenshot(screenshot(`input-slots-label-stacked-fill-outline`));
});
});
});
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 }) => {