diff --git a/field/lib/_content.scss b/field/lib/_content.scss index a63cf2b9ea0..8a0598cb76f 100644 --- a/field/lib/_content.scss +++ b/field/lib/_content.scss @@ -95,9 +95,18 @@ $_enter-delay: $_label-duration - $_visible-duration; // below. color: currentColor; font: var(--_content-type); + width: 100%; + } + + .content ::slotted(:not(textarea)) { padding-top: var(--_top-space); padding-bottom: var(--_bottom-space); - width: 100%; + } + + .content ::slotted(textarea) { + // Use margin for textareas since they will scroll over the label if not. + margin-top: var(--_top-space); + margin-bottom: var(--_bottom-space); } :hover .content { diff --git a/field/lib/_filled-field.scss b/field/lib/_filled-field.scss index 00c36811bec..4ec29a3da1c 100644 --- a/field/lib/_filled-field.scss +++ b/field/lib/_filled-field.scss @@ -95,11 +95,11 @@ $_md-sys-motion: tokens.md-sys-motion-values(); top: var(--_with-label-top-space); } - .field:not(.with-start) .label-space { + .field:not(.with-start) .label-wrapper { margin-inline-start: var(--_leading-space); } - .field:not(.with-end) .label-space { + .field:not(.with-end) .label-wrapper { margin-inline-end: var(--_trailing-space); } @@ -141,13 +141,21 @@ $_md-sys-motion: tokens.md-sys-motion-values(); padding-inline-end: var(--_trailing-space); } - .field:not(.no-label) .content ::slotted(*) { + .field:not(.no-label) .content ::slotted(:not(textarea)) { padding-bottom: var(--_with-label-bottom-space); padding-top: calc( var(--_with-label-top-space) + var(--_label-text-populated-line-height) ); } + .field:not(.no-label) .content ::slotted(textarea) { + // Use margin for textareas since they will scroll over the label if not. + margin-bottom: var(--_with-label-bottom-space); + margin-top: calc( + var(--_with-label-top-space) + var(--_label-text-populated-line-height) + ); + } + :hover .active-indicator::before { border-bottom-color: var(--_hover-active-indicator-color); border-bottom-width: var(--_hover-active-indicator-height); diff --git a/field/lib/_label.scss b/field/lib/_label.scss index 9f2dff3f3b9..8af24169cf2 100644 --- a/field/lib/_label.scss +++ b/field/lib/_label.scss @@ -9,13 +9,6 @@ color: var(--_label-text-color); overflow: hidden; max-width: 100%; - // The resting label at 100% height can block pointer events to the content - // if it's very long and spans the full width of the field. Additionally, - // selecting the label's text doesn't present a good UX, since the user - // selection should be re-focused to another element (such as the input) - // upon focusing. Finally, since the actual label elements are swapped, it - // is not easy to maintain the user's label text selection. - pointer-events: none; // TODO: Check with design, should there be any transition from resting to // floating when there is a mismatch between ellipsis, such as opacity // transition? @@ -26,6 +19,18 @@ width: min-content; } + .label-wrapper { + inset: 0; + // The resting label at 100% height can block pointer events to the content + // if it's very long and spans the full width of the field. Additionally, + // selecting the label's text doesn't present a good UX, since the user + // selection should be re-focused to another element (such as the input) + // upon focusing. Finally, since the actual label elements are swapped, it + // is not easy to maintain the user's label text selection. + pointer-events: none; + position: absolute; + } + .label.resting { position: absolute; top: var(--_top-space); @@ -48,7 +53,7 @@ // Labels need start/end padding when there isn't start/end content so they // don't sit on the edge of the field. We use a wrapper element around the // labels so as not to affect the dimensions used in the label keyframes. - .label-space { + .label-wrapper { inset: 0; position: absolute; // Don't let setting text-align on the field change the label's alignment. diff --git a/field/lib/_outlined-field.scss b/field/lib/_outlined-field.scss index f1dfb30d5e3..9bcbf7961b9 100644 --- a/field/lib/_outlined-field.scss +++ b/field/lib/_outlined-field.scss @@ -237,7 +237,7 @@ $_md-sys-motion: tokens.md-sys-motion-values(); padding-inline-start: $start-space; } - .field:not(.with-start) .label-space { + .field:not(.with-start) .label-wrapper { margin-inline-start: $start-space; } @@ -245,7 +245,7 @@ $_md-sys-motion: tokens.md-sys-motion-values(); padding-inline-end: $end-space; } - .field:not(.with-end) .label-space { + .field:not(.with-end) .label-wrapper { margin-inline-end: $end-space; } diff --git a/field/lib/_shared.scss b/field/lib/_shared.scss index 0f7ca3da8a2..e3045f7d815 100644 --- a/field/lib/_shared.scss +++ b/field/lib/_shared.scss @@ -44,6 +44,7 @@ border-radius: inherit; display: flex; flex: 1; + min-height: 100%; min-width: min-content; overflow: hidden; position: relative; @@ -52,6 +53,7 @@ .field, .container-overflow, .container { + height: 100%; resize: inherit; } diff --git a/field/lib/field.ts b/field/lib/field.ts index 4026d38a73e..fc1abcb5b0d 100644 --- a/field/lib/field.ts +++ b/field/lib/field.ts @@ -116,7 +116,7 @@ export class Field extends LitElement implements SurfacePositionTarget {
-
+
${restingLabel} ${outline ? nothing : floatingLabel}
diff --git a/textfield/demo/demo.ts b/textfield/demo/demo.ts index c3557135c80..818bb6ed69e 100644 --- a/textfield/demo/demo.ts +++ b/textfield/demo/demo.ts @@ -15,6 +15,7 @@ import {stories, StoryKnobs} from './stories.js'; const collection = new MaterialCollection>('Textfield', [ new Knob('label', {ui: textInput(), defaultValue: 'Label'}), + new Knob('textarea', {ui: boolInput(), defaultValue: false}), new Knob('disabled', {ui: boolInput(), defaultValue: false}), new Knob('required', {ui: boolInput(), defaultValue: false}), new Knob('prefixText', {ui: textInput(), defaultValue: ''}), diff --git a/textfield/demo/stories.ts b/textfield/demo/stories.ts index 0b5603a9248..bbc43320688 100644 --- a/textfield/demo/stories.ts +++ b/textfield/demo/stories.ts @@ -10,11 +10,12 @@ import '@material/web/textfield/outlined-text-field.js'; import {MaterialStoryInit} from './material-collection.js'; import {MdFilledTextField} from '@material/web/textfield/filled-text-field.js'; -import {html, nothing} from 'lit'; +import {css, html, nothing} from 'lit'; /** Knob types for Textfield stories. */ export interface StoryKnobs { label: string; + textarea: boolean; disabled: boolean; required: boolean; prefixText: string; @@ -30,23 +31,36 @@ export interface StoryKnobs { 'trailing icon': boolean; } +// Set min-height for resizable textareas +const styles = css` + [type=textarea] { + min-height: 56px; + } + + [type=textarea][supporting-text] { + min-height: 76px; + } +`; + const filled: MaterialStoryInit = { name: '', + styles, render(knobs) { return html` ${knobs['leading icon'] ? LEADING_ICON : nothing} @@ -58,21 +72,23 @@ const filled: MaterialStoryInit = { const outlined: MaterialStoryInit = { name: '', + styles, render(knobs) { return html` ${knobs['leading icon'] ? LEADING_ICON : nothing} diff --git a/textfield/lib/_input.scss b/textfield/lib/_input.scss index 2703cf43626..cefabe277e5 100644 --- a/textfield/lib/_input.scss +++ b/textfield/lib/_input.scss @@ -4,19 +4,22 @@ // @mixin styles() { - .content { + .input-wrapper { display: flex; } - input, - .prefix, - .suffix { + .input-wrapper > * { + // Inherit field CSS set on the input wrapper, like font, but not margin or + // padding. This wrapper is needed since text fields may have prefix and + // suffix text next to an all: inherit; padding: 0; } - input { + .input { caret-color: var(--_caret-color); + // remove extra height added by horizontal scrollbars + overflow-x: hidden; text-align: inherit; &::placeholder { @@ -34,11 +37,11 @@ } } - :focus-within input { + :focus-within .input { caret-color: var(--_focus-caret-color); } - .error:focus-within input { + .error:focus-within .input { caret-color: var(--_error-focus-caret-color); } @@ -50,7 +53,7 @@ color: var(--_input-text-suffix-color); } - .text-field:not(.disabled) input::placeholder { + .text-field:not(.disabled) .input::placeholder { color: var(--_input-text-placeholder-color); } diff --git a/textfield/lib/_shared.scss b/textfield/lib/_shared.scss index 2b6e8fd7cc7..adca102cf92 100644 --- a/textfield/lib/_shared.scss +++ b/textfield/lib/_shared.scss @@ -15,23 +15,33 @@ :host { display: inline-flex; outline: none; + resize: both; -webkit-tap-highlight-color: transparent; } + .text-field, + .field { + width: 100%; + } + .text-field { display: inline-flex; - flex: 1; } .field { cursor: text; - flex: 1; } .disabled .field { cursor: default; } + .text-field, + .textarea .field { + // Note: only inherit default `resize: both` to the field when textarea. + resize: inherit; + } + @include icon.styles; @include input.styles; } diff --git a/textfield/lib/text-field.ts b/textfield/lib/text-field.ts index 587e646ab62..02322235e9c 100644 --- a/textfield/lib/text-field.ts +++ b/textfield/lib/text-field.ts @@ -21,7 +21,7 @@ import {stringConverter} from '../../internal/controller/string-converter.js'; * Input types that are compatible with the text field. */ export type TextFieldType = - 'email'|'number'|'password'|'search'|'tel'|'text'|'url'; + 'email'|'number'|'password'|'search'|'tel'|'text'|'url'|'textarea'; /** * Input types that are not fully supported for the text field. @@ -101,6 +101,12 @@ export abstract class TextField extends LitElement { */ @property({attribute: 'text-direction'}) textDirection = ''; + /** + * The number of rows to display for a `type="textarea"` text field. + * Defaults to 2. + */ + @property({type: Number}) rows = 2; + /** * The associated form element with which this element's value will submit. */ @@ -173,30 +179,30 @@ export abstract class TextField extends LitElement { * Gets or sets the direction in which selection occurred. */ get selectionDirection() { - return this.getInput().selectionDirection; + return this.getInputOrTextarea().selectionDirection; } set selectionDirection(value: 'forward'|'backward'|'none'|null) { - this.getInput().selectionDirection = value; + this.getInputOrTextarea().selectionDirection = value; } /** * Gets or sets the end position or offset of a text selection. */ get selectionEnd() { - return this.getInput().selectionEnd; + return this.getInputOrTextarea().selectionEnd; } set selectionEnd(value: number|null) { - this.getInput().selectionEnd = value; + this.getInputOrTextarea().selectionEnd = value; } /** * Gets or sets the starting position or offset of a text selection. */ get selectionStart() { - return this.getInput().selectionStart; + return this.getInputOrTextarea().selectionStart; } set selectionStart(value: number|null) { - this.getInput().selectionStart = value; + this.getInputOrTextarea().selectionStart = value; } /** @@ -217,7 +223,7 @@ export abstract class TextField extends LitElement { * https://developer.mozilla.org/en-US/docs/Web/API/HTMLObjectElement/validationMessage */ get validationMessage() { - return this.getInput().validationMessage; + return this.getInputOrTextarea().validationMessage; } /** @@ -227,29 +233,49 @@ export abstract class TextField extends LitElement { * https://developer.mozilla.org/en-US/docs/Web/API/HTMLObjectElement/validity */ get validity() { - return this.getInput().validity; + return this.getInputOrTextarea().validity; } /** * The text field's value as a number. */ get valueAsNumber() { - return this.getInput().valueAsNumber; + const input = this.getInput(); + if (!input) { + return NaN; + } + + return input.valueAsNumber; } set valueAsNumber(value: number) { - this.getInput().valueAsNumber = value; - this.value = this.getInput().value; + const input = this.getInput(); + if (!input) { + return; + } + + input.valueAsNumber = value; + this.value = input.value; } /** * The text field's value as a Date. */ get valueAsDate() { - return this.getInput().valueAsDate; + const input = this.getInput(); + if (!input) { + return null; + } + + return input.valueAsDate; } set valueAsDate(value: Date|null) { - this.getInput().valueAsDate = value; - this.value = this.getInput().value; + const input = this.getInput(); + if (!input) { + return; + } + + input.valueAsDate = value; + this.value = input.value; } /** @@ -259,7 +285,7 @@ export abstract class TextField extends LitElement { * https://developer.mozilla.org/en-US/docs/Web/API/HTMLObjectElement/willValidate */ get willValidate() { - return this.getInput().willValidate; + return this.getInputOrTextarea().willValidate; } protected abstract readonly fieldTag: StaticValue; @@ -284,7 +310,8 @@ export abstract class TextField extends LitElement { return this.error || this.nativeError; } - @query('input') private readonly input?: HTMLInputElement|null; + @query('.input') + private readonly inputOrTextarea?: HTMLInputElement|HTMLTextAreaElement|null; @query('.field') private readonly field?: Field|null; @queryAssignedElements({slot: 'leadingicon'}) private readonly leadingIcons!: Element[]; @@ -369,7 +396,7 @@ export abstract class TextField extends LitElement { * https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/select */ select() { - this.getInput().select(); + this.getInputOrTextarea().select(); } /** @@ -384,7 +411,7 @@ export abstract class TextField extends LitElement { * @param error The error message to display. */ setCustomValidity(error: string) { - this.getInput().setCustomValidity(error); + this.getInputOrTextarea().setCustomValidity(error); } /** @@ -399,9 +426,9 @@ export abstract class TextField extends LitElement { setRangeText(...args: unknown[]) { // Calling setRangeText with 1 vs 3-4 arguments has different behavior. // Use spread syntax and type casting to ensure correct usage. - this.getInput().setRangeText( + this.getInputOrTextarea().setRangeText( ...args as Parameters); - this.value = this.getInput().value; + this.value = this.getInputOrTextarea().value; } /** @@ -416,7 +443,7 @@ export abstract class TextField extends LitElement { setSelectionRange( start: number|null, end: number|null, direction?: 'forward'|'backward'|'none') { - this.getInput().setSelectionRange(start, end, direction); + this.getInputOrTextarea().setSelectionRange(start, end, direction); } /** @@ -429,6 +456,10 @@ export abstract class TextField extends LitElement { */ stepDown(stepDecrement?: number) { const input = this.getInput(); + if (!input) { + return; + } + input.stepDown(stepDecrement); this.value = input.value; } @@ -443,6 +474,10 @@ export abstract class TextField extends LitElement { */ stepUp(stepIncrement?: number) { const input = this.getInput(); + if (!input) { + return; + } + input.stepUp(stepIncrement); this.value = input.value; } @@ -472,6 +507,7 @@ export abstract class TextField extends LitElement { const classes = { 'disabled': this.disabled, 'error': !this.disabled && this.hasError, + 'textarea': this.type === 'textarea', }; return html` @@ -486,7 +522,7 @@ export abstract class TextField extends LitElement { // If a property such as `type` changes and causes the internal // value to change without dispatching an event, re-sync it. - const value = this.getInput().value; + const value = this.getInputOrTextarea().value; this.internals.setFormValue(value); if (this.value !== value) { // Note this is typically inefficient in updated() since it schedules @@ -497,28 +533,25 @@ export abstract class TextField extends LitElement { } private renderField() { - const prefix = this.renderPrefix(); - const suffix = this.renderSuffix(); - const input = this.renderInput(); - return staticHtml`<${this.fieldTag} class="field" + count=${this.value.length} ?disabled=${this.disabled} ?error=${this.hasError} + error-text=${this.getErrorText()} ?focused=${this.focused} ?has-end=${this.hasTrailingIcon} ?has-start=${this.hasLeadingIcon} label=${this.label} + max=${this.maxLength} ?populated=${!!this.value} ?required=${this.required} supporting-text=${this.supportingText} - error-text=${this.getErrorText()} - count=${this.value.length} - max=${this.maxLength} > ${this.renderLeadingIcon()} -
${prefix}${input}${suffix}
+ ${this.renderInputOrTextarea()} ${this.renderTrailingIcon()} +
`; } @@ -538,35 +571,66 @@ export abstract class TextField extends LitElement { `; } - private renderInput() { + private renderInputOrTextarea() { const style = {direction: this.textDirection}; + const ariaLabel = + (this as ARIAMixinStrict).ariaLabel || this.label || nothing; + + if (this.type === 'textarea') { + return html` + + `; + } + + const prefix = this.renderPrefix(); + const suffix = this.renderSuffix(); // TODO(b/243805848): remove `as unknown as number` once lit analyzer is // fixed return html` - -1 ? this.maxLength : nothing} - min=${(this.min || nothing) as unknown as number} - minlength=${this.minLength > -1 ? this.minLength : nothing} - pattern=${this.pattern || nothing} - placeholder=${this.placeholder || nothing} - ?readonly=${this.readOnly} - ?required=${this.required} - step=${(this.step || nothing) as unknown as number} - type=${this.type} - .value=${live(this.value)} - @change=${this.redispatchEvent} - @input=${this.handleInput} - @select=${this.redispatchEvent} - > -
+
+ ${prefix} + -1 ? this.maxLength : nothing} + min=${(this.min || nothing) as unknown as number} + minlength=${this.minLength > -1 ? this.minLength : nothing} + pattern=${this.pattern || nothing} + placeholder=${this.placeholder || nothing} + ?readonly=${this.readOnly} + ?required=${this.required} + step=${(this.step || nothing) as unknown as number} + type=${this.type} + .value=${live(this.value)} + @change=${this.redispatchEvent} + @input=${this.handleInput} + @select=${this.redispatchEvent} + > + ${suffix} +
`; } @@ -618,8 +682,8 @@ export abstract class TextField extends LitElement { redispatchEvent(this, event); } - private getInput() { - if (!this.input) { + private getInputOrTextarea() { + if (!this.inputOrTextarea) { // If the input is not yet defined, synchronously render. // e.g. // const textField = document.createElement('md-outlined-text-field'); @@ -636,11 +700,19 @@ export abstract class TextField extends LitElement { this.scheduleUpdate(); } - return this.input!; + return this.inputOrTextarea!; + } + + private getInput() { + if (this.type === 'textarea') { + return null; + } + + return this.getInputOrTextarea() as HTMLInputElement; } private checkValidityAndDispatch() { - const valid = this.getInput().checkValidity(); + const valid = this.getInputOrTextarea().checkValidity(); let canceled = false; if (!valid) { canceled = !this.dispatchEvent(new Event('invalid', {cancelable: true}));