feat(textarea): add ionic theme design with medium size (#29776)

- Adds the typography styles for the default size (medium)
- Adds the styles for the outline fill 
- Adds the styles for the label & helper text
- Adds the spacing for the label, textarea, counter and helper text
This commit is contained in:
Brandy Carney
2024-08-21 14:55:40 -04:00
committed by GitHub
parent 59ba289233
commit b57f4be284
23 changed files with 470 additions and 104 deletions

View File

@ -2280,6 +2280,7 @@ ion-textarea,prop,readonly,boolean,false,false,false
ion-textarea,prop,required,boolean,false,false,false
ion-textarea,prop,rows,number | undefined,undefined,false,false
ion-textarea,prop,shape,"round" | undefined,undefined,false,false
ion-textarea,prop,size,"large" | "medium" | "small" | undefined,'medium',false,false
ion-textarea,prop,spellcheck,boolean,false,false,false
ion-textarea,prop,theme,"ios" | "md" | "ionic",undefined,false,false
ion-textarea,prop,value,null | string | undefined,'',false,false

View File

@ -3620,6 +3620,10 @@ export namespace Components {
* The shape of the textarea. If "round" it will have an increased border radius.
*/
"shape"?: 'round';
/**
* The size of the textarea. If "large", it will have an increased height. By default the size is "medium". This property only applies to the `"ionic"` theme.
*/
"size"?: 'small' | 'medium' | 'large';
/**
* If `true`, the element will have its spelling and grammar checked.
*/
@ -8976,6 +8980,10 @@ declare namespace LocalJSX {
* The shape of the textarea. If "round" it will have an increased border radius.
*/
"shape"?: 'round';
/**
* The size of the textarea. If "large", it will have an increased height. By default the size is "medium". This property only applies to the `"ionic"` theme.
*/
"size"?: 'small' | 'medium' | 'large';
/**
* If `true`, the element will have its spelling and grammar checked.
*/

View File

@ -22,8 +22,18 @@
</ion-toolbar>
</ion-header>
<ion-content id="content">
<ion-textarea label="Textarea"></ion-textarea>
<ion-content id="content" class="ion-padding">
<ion-textarea
fill="outline"
label="Label"
label-placement="stacked"
placeholder="Placeholder"
helper-text="Helper message"
counter="true"
maxlength="999"
>
<ion-icon slot="end" name="square-outline"></ion-icon>
</ion-textarea>
</ion-content>
</ion-app>
</body>

View File

@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Textarea - Size</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(2, minmax(250px, 1fr));
grid-row-gap: 20px;
grid-column-gap: 20px;
}
h2 {
font-size: 12px;
font-weight: normal;
color: #6f7378;
margin-top: 10px;
padding-left: 16px;
}
@media screen and (max-width: 800px) {
.grid {
grid-template-columns: 1fr;
padding: 0;
}
}
</style>
</head>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Textarea - Size</ion-title>
</ion-toolbar>
</ion-header>
<ion-content id="content" class="ion-padding">
<div class="grid">
<div class="grid-item">
<h2>No Fill: No Size</h2>
<ion-textarea label="Label" label-placement="stacked" placeholder="Placeholder"></ion-textarea>
</div>
<div class="grid-item">
<h2>Outline: No Size</h2>
<ion-textarea fill="outline" label="Label" label-placement="stacked"></ion-textarea>
</div>
<div class="grid-item">
<h2>No Fill: No Size, Round Shape</h2>
<ion-textarea shape="round" label="Label" label-placement="stacked"></ion-textarea>
</div>
<div class="grid-item">
<h2>Outline: No Size, Round Shape</h2>
<ion-textarea fill="outline" shape="round" label="Label" label-placement="stacked"></ion-textarea>
</div>
</div>
</ion-content>
</ion-app>
</body>
</html>

View File

@ -0,0 +1,57 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
/**
* Size is only available in the Ionic theme
*/
configs({ modes: ['ionic-md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('textarea: size'), () => {
test.describe('textarea: size medium', () => {
test('should not have visual regressions', async ({ page }) => {
await page.setContent(
`
<ion-textarea
label="Email"
value="hi@ionic.io"
></ion-textarea>
`,
config
);
const textarea = page.locator('ion-textarea');
await expect(textarea).toHaveScreenshot(screenshot(`textarea-size-medium`));
});
test('should render correctly with stacked label', async ({ page }) => {
await page.setContent(
`
<ion-textarea
label="Email"
label-placement="stacked"
value="hi@ionic.io"
></ion-textarea>
`,
config
);
const textarea = page.locator('ion-textarea');
await expect(textarea).toHaveScreenshot(screenshot(`textarea-size-medium-label-stacked`));
});
test('should not have visual regressions with fill outline', async ({ page }) => {
await page.setContent(
`
<ion-textarea
fill="outline"
label="Email"
label-placement="stacked"
value="hi@ionic.io"
></ion-textarea>
`,
config
);
const textarea = page.locator('ion-textarea');
await expect(textarea).toHaveScreenshot(screenshot(`textarea-size-medium-outline`));
});
});
});
});

View File

@ -59,35 +59,11 @@
width: 100%;
min-height: 44px;
color: var(--color);
font-family: $font-family-base;
z-index: $z-index-item-input;
box-sizing: border-box;
}
// Textarea Wrapper
// ----------------------------------------------------------------
/**
* Since the label sits on top of the element,
* the component needs to be taller otherwise the
* label will appear too close to the textarea text.
* Also, floating and stacked labels should not
* push the label down since it it
* sits on top of the textarea.
*/
:host(.textarea-label-placement-floating),
:host(.textarea-label-placement-stacked) {
--padding-top: 0px;
min-height: 56px;
}
/**
* When the cols property is set we should
* respect that width instead of defaulting
@ -279,17 +255,11 @@
caret-color: var(--highlight-color);
}
.native-wrapper textarea {
@include padding(var(--padding-top), 0px, var(--padding-bottom), 0px);
}
.native-wrapper {
display: grid;
min-width: inherit;
max-width: inherit;
min-height: inherit;
max-height: inherit;
/**
* This avoids a WebKit bug where
@ -375,20 +345,11 @@
// ----------------------------------------------------------------
.textarea-bottom {
/**
* The bottom content should take on the start and end
* padding so it is always aligned with either the label
* or the start of the textarea.
*/
@include padding(5px, var(--padding-end), 0, var(--padding-start));
display: flex;
justify-content: space-between;
border-top: var(--border-width) var(--border-style) var(--border-color);
font-size: dynamic-font(12px);
}
/**
@ -417,7 +378,7 @@
.textarea-bottom .helper-text {
display: block;
color: #{$text-color-step-450};
color: $text-color-step-450;
}
:host(.ion-touched.ion-invalid) .textarea-bottom .error-text {
@ -439,11 +400,7 @@
*/
@include margin-horizontal(auto, null);
color: #{$text-color-step-450};
white-space: nowrap;
padding-inline-start: 16px;
}
// Textarea Label
@ -524,15 +481,6 @@
flex-direction: row;
}
:host(.textarea-label-placement-start) .label-text-wrapper {
/**
* The margin between the label and
* the textarea should be on the end
* when the label sits at the start.
*/
@include margin(0, $form-control-label-margin, 0, 0);
}
// Textarea Label Placement - End
// ----------------------------------------------------------------
@ -544,27 +492,9 @@
flex-direction: row-reverse;
}
/**
* The margin between the label and
* the textarea should be on the start
* when the label sits at the end.
*/
:host(.textarea-label-placement-end) .label-text-wrapper {
@include margin(0, 0, 0, $form-control-label-margin);
}
// Textarea Label Placement - Fixed
// ----------------------------------------------------------------
:host(.textarea-label-placement-fixed) .label-text-wrapper {
/**
* The margin between the label and
* the textarea should be on the end
* when the label sits at the start.
*/
@include margin(0, $form-control-label-margin, 0, 0);
}
/**
* Label is on the left of the textarea in LTR and
* on the right in RTL. Label also has a fixed width.
@ -624,13 +554,6 @@
@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.
@ -671,26 +594,9 @@
.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"]:last-of-type) {
margin-inline-end: $form-control-label-margin;
margin-inline-start: 0;
}
::slotted([slot="end"]:first-of-type) {
margin-inline-start: $form-control-label-margin;
margin-inline-end: 0;
}

View File

@ -0,0 +1,55 @@
@use "../../themes/ionic/ionic.globals.scss" as globals;
// Textarea Fill: Outline
// ----------------------------------------------------------------
:host(.textarea-fill-outline) {
--border-width: #{globals.$ionic-border-size-025};
--border-color: #{globals.$ionic-color-neutral-500};
}
// Textarea Fill: Outline, Textarea Wrapper
// ----------------------------------------------------------------
:host(.textarea-fill-outline) .textarea-wrapper-inner {
/**
* The border should be relative to the inner wrapper
* so that it does not include the label.
*/
position: relative;
}
// Textarea Fill: Outline, Outline Container
// ----------------------------------------------------------------
:host(.textarea-fill-outline) .textarea-outline {
@include globals.position(0, 0, 0, 0);
@include globals.border-radius(var(--border-radius));
position: absolute;
width: 100%;
height: 100%;
pointer-events: none;
border: var(--border-width) var(--border-style) var(--border-color);
}
// Textarea Fill: Outline, Bottom Content
// ----------------------------------------------------------------
/**
* The bottom content should never have
* a border with the outline style.
*/
:host(.textarea-fill-outline) .textarea-bottom {
border-top: none;
}
// Textarea Fill: Outline, Native Textarea
// ----------------------------------------------------------------
:host(.textarea-fill-outline) textarea {
margin-top: globals.$ionic-space-100;
}

View File

@ -0,0 +1,101 @@
@use "../../themes/ionic/ionic.globals.scss" as globals;
@use "./textarea.common";
@use "./textarea.ionic.outline.scss" as outline;
// Ionic Textarea
// --------------------------------------------------
:host {
--border-radius: #{globals.$ionic-border-radius-400};
--color: #{globals.$ionic-color-neutral-1200};
--highlight-color-valid: #{globals.$ionic-color-success-base};
--highlight-color-invalid: #{globals.$ionic-color-danger-base};
--placeholder-color: #{globals.$ionic-color-neutral-800};
--placeholder-opacity: 1;
--background: #{globals.$ionic-color-base-white};
--padding-top: #{globals.$ionic-space-300};
--padding-end: #{globals.$ionic-space-400};
--padding-bottom: #{globals.$ionic-space-300};
--padding-start: #{globals.$ionic-space-400};
@include globals.typography(globals.$ionic-body-md-regular);
}
// Ionic Textarea Sizes
// --------------------------------------------------
// Setting height to 0 allows it to collapse in height
// instead of growing above the min height by default.
.textarea-wrapper-inner {
height: 0;
}
:host(.textarea-size-medium) .textarea-wrapper-inner {
min-height: globals.$ionic-scale-3400;
}
// Textarea Wrapper
// ----------------------------------------------------------------
.textarea-wrapper {
gap: globals.$ionic-space-100;
}
.textarea-wrapper-inner {
@include globals.padding(var(--padding-top), var(--padding-end), var(--padding-bottom), var(--padding-start));
}
// Textarea Auto Grow
// ----------------------------------------------------------------
// The height should be auto only when auto-grow is enabled.
:host([auto-grow]) .textarea-wrapper-inner {
height: auto;
}
// The min and max height should be inherited if auto-grow is enabled.
// This allows the textarea to grow and shrink as needed.
:host([auto-grow]) .native-wrapper {
min-height: inherit;
max-height: inherit;
}
// Textarea Label
// ----------------------------------------------------------------
.label-text-wrapper {
@include globals.typography(globals.$ionic-body-sm-medium);
color: globals.$ionic-color-neutral-1000;
}
:host(.label-floating) .label-text-wrapper {
@include globals.transform(none);
}
// Textarea Slotted Content
// ----------------------------------------------------------------
ion-icon {
color: globals.$ionic-color-neutral-800;
font-size: globals.$ionic-scale-400;
}
.start-slot-wrapper,
.end-slot-wrapper {
margin-top: globals.$ionic-space-050;
}
// Textarea Bottom Content
// ----------------------------------------------------------------
.textarea-bottom {
@include globals.padding(globals.$ionic-space-100, var(--padding-end), null, var(--padding-start));
@include globals.typography(globals.$ionic-body-sm-medium);
}
.textarea-bottom .helper-text,
.textarea-bottom .counter {
color: globals.$ionic-color-neutral-800;
}

View File

@ -1,4 +1,4 @@
@import "./textarea";
@import "./textarea.native";
@import "./textarea.vars";
@import "./textarea.ios.vars";

View File

@ -1,4 +1,4 @@
@import "./textarea";
@import "./textarea.native";
@import "./textarea.vars";
@import "./textarea.md.vars";
@import "./textarea.md.solid";

View File

@ -0,0 +1,133 @@
@use "../../themes/native/native.globals" as globals;
@use "./textarea.common";
// Textarea - iOS and Material Design
// --------------------------------------------------
:host {
min-height: 44px;
font-family: globals.$font-family-base;
z-index: globals.$z-index-item-input;
}
// Textarea Wrapper
// ----------------------------------------------------------------
/**
* Since the label sits on top of the element,
* the component needs to be taller otherwise the
* label will appear too close to the textarea text.
* Also, floating and stacked labels should not
* push the label down since it it
* sits on top of the textarea.
*/
:host(.textarea-label-placement-floating),
:host(.textarea-label-placement-stacked) {
--padding-top: 0px;
min-height: 56px;
}
// Textarea Native
// ----------------------------------------------------------------
// This is required for auto-grow to work.
.native-wrapper {
min-height: inherit;
max-height: inherit;
}
.native-wrapper textarea {
@include globals.padding(var(--padding-top), 0px, var(--padding-bottom), 0px);
}
// Textarea Bottom Content
// ----------------------------------------------------------------
.textarea-bottom {
/**
* The bottom content should take on the start and end
* padding so it is always aligned with either the label
* or the start of the textarea.
*/
@include globals.padding(5px, var(--padding-end), 0, var(--padding-start));
font-size: globals.dynamic-font(12px);
}
// Textarea Max Length Counter
// ----------------------------------------------------------------
.textarea-bottom .counter {
color: #{globals.$text-color-step-450};
padding-inline-start: 16px;
}
// Textarea Label Placement - Start
// ----------------------------------------------------------------
:host(.textarea-label-placement-start) .label-text-wrapper {
/**
* The margin between the label and
* the textarea should be on the end
* when the label sits at the start.
*/
@include globals.margin(0, globals.$form-control-label-margin, 0, 0);
}
// Textarea Label Placement - End
// ----------------------------------------------------------------
/**
* The margin between the label and
* the textarea should be on the start
* when the label sits at the end.
*/
:host(.textarea-label-placement-end) .label-text-wrapper {
@include globals.margin(0, 0, 0, globals.$form-control-label-margin);
}
// Textarea Label Placement - Fixed
// ----------------------------------------------------------------
:host(.textarea-label-placement-fixed) .label-text-wrapper {
/**
* The margin between the label and
* the textarea should be on the end
* when the label sits at the start.
*/
@include globals.margin(0, globals.$form-control-label-margin, 0, 0);
}
// Start / End Slots
// ----------------------------------------------------------------
.start-slot-wrapper,
.end-slot-wrapper {
@include globals.padding(var(--padding-top), 0, var(--padding-bottom), 0);
}
: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;
}
::slotted([slot="start"]),
::slotted([slot="end"]) {
margin-top: 0; // ensure slot content is vertically aligned with label
}
::slotted([slot="start"]:last-of-type) {
margin-inline-end: globals.$form-control-label-margin;
margin-inline-start: 0;
}
::slotted([slot="end"]:first-of-type) {
margin-inline-start: globals.$form-control-label-margin;
margin-inline-end: 0;
}

View File

@ -40,7 +40,7 @@ import type { TextareaChangeEventDetail, TextareaInputEventDetail } from './text
styleUrls: {
ios: 'textarea.ios.scss',
md: 'textarea.md.scss',
ionic: 'textarea.md.scss',
ionic: 'textarea.ionic.scss',
},
scoped: true,
})
@ -248,6 +248,12 @@ export class Textarea implements ComponentInterface {
*/
@Prop() shape?: 'round';
/**
* The size of the textarea. If "large", it will have an increased height. By default the
* size is "medium". This property only applies to the `"ionic"` theme.
*/
@Prop() size?: 'small' | 'medium' | 'large' = 'medium';
/**
* Update the native input element when the value changes
*/
@ -619,7 +625,7 @@ export class Textarea implements ComponentInterface {
}
render() {
const { inputId, disabled, fill, shape, labelPlacement, el, hasFocus } = this;
const { inputId, disabled, fill, shape, size, labelPlacement, el, hasFocus } = this;
const theme = getIonTheme(this);
const value = this.getValue();
const inItem = hostContext('ion-item', this.el);
@ -657,6 +663,7 @@ export class Textarea implements ComponentInterface {
'label-floating': labelShouldFloat,
[`textarea-fill-${fill}`]: fill !== undefined,
[`textarea-shape-${shape}`]: shape !== undefined,
[`textarea-size-${size}`]: true,
[`textarea-label-placement-${labelPlacement}`]: true,
'textarea-disabled': disabled,
})}
@ -670,6 +677,18 @@ export class Textarea implements ComponentInterface {
<label class="textarea-wrapper" htmlFor={inputId}>
{this.renderLabelContainer()}
<div class="textarea-wrapper-inner">
{
/**
* 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 textarea.
*/
theme === 'ionic' && fill === 'outline' && <div class="textarea-outline"></div>
}
{/**
* Some elements have their own padding styles which may
* interfere with slot content alignment (such as icon-

View File

@ -2221,7 +2221,7 @@ export declare interface IonText extends Components.IonText {}
@ProxyCmp({
inputs: ['autoGrow', 'autocapitalize', 'autofocus', 'clearOnEdit', 'color', 'cols', 'counter', 'counterFormatter', 'debounce', 'disabled', 'enterkeyhint', 'errorText', 'fill', 'helperText', 'inputmode', 'label', 'labelPlacement', 'maxlength', 'minlength', 'mode', 'name', 'placeholder', 'readonly', 'required', 'rows', 'shape', 'spellcheck', 'theme', 'value', 'wrap'],
inputs: ['autoGrow', 'autocapitalize', 'autofocus', 'clearOnEdit', 'color', 'cols', 'counter', 'counterFormatter', 'debounce', 'disabled', 'enterkeyhint', 'errorText', 'fill', 'helperText', 'inputmode', 'label', 'labelPlacement', 'maxlength', 'minlength', 'mode', 'name', 'placeholder', 'readonly', 'required', 'rows', 'shape', 'size', 'spellcheck', 'theme', 'value', 'wrap'],
methods: ['setFocus', 'getInputElement']
})
@Component({
@ -2229,7 +2229,7 @@ export declare interface IonText extends Components.IonText {}
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['autoGrow', 'autocapitalize', 'autofocus', 'clearOnEdit', 'color', 'cols', 'counter', 'counterFormatter', 'debounce', 'disabled', 'enterkeyhint', 'errorText', 'fill', 'helperText', 'inputmode', 'label', 'labelPlacement', 'maxlength', 'minlength', 'mode', 'name', 'placeholder', 'readonly', 'required', 'rows', 'shape', 'spellcheck', 'theme', 'value', 'wrap'],
inputs: ['autoGrow', 'autocapitalize', 'autofocus', 'clearOnEdit', 'color', 'cols', 'counter', 'counterFormatter', 'debounce', 'disabled', 'enterkeyhint', 'errorText', 'fill', 'helperText', 'inputmode', 'label', 'labelPlacement', 'maxlength', 'minlength', 'mode', 'name', 'placeholder', 'readonly', 'required', 'rows', 'shape', 'size', 'spellcheck', 'theme', 'value', 'wrap'],
})
export class IonTextarea {
protected el: HTMLElement;

View File

@ -858,6 +858,7 @@ export const IonTextarea = /*@__PURE__*/ defineContainer<JSX.IonTextarea, JSX.Io
'label',
'labelPlacement',
'shape',
'size',
'ionChange',
'ionInput',
'ionBlur',