diff --git a/field/lib/_label.scss b/field/lib/_label.scss index c2725497e5..8af24169cf 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); diff --git a/field/lib/_shared.scss b/field/lib/_shared.scss index 0f7ca3da8a..e569067f2a 100644 --- a/field/lib/_shared.scss +++ b/field/lib/_shared.scss @@ -36,6 +36,7 @@ border-end-end-radius: var(--_container-shape-end-end); border-end-start-radius: var(--_container-shape-end-start); display: flex; + height: 100%; position: relative; } @@ -44,6 +45,8 @@ border-radius: inherit; display: flex; flex: 1; + max-height: 100%; + min-height: 100%; min-width: min-content; overflow: hidden; position: relative; @@ -51,7 +54,9 @@ .field, .container-overflow, - .container { + .field:not(.disabled) .container { + // Inherit `resize` set on host, but only inherit it for the actual + // container if the field is not disabled. resize: inherit; } diff --git a/textfield/demo/demo.ts b/textfield/demo/demo.ts index c3557135c8..818bb6ed69 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 de96f26ea1..0b19b3c064 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/harness.ts b/textfield/harness.ts index 26cda245b2..e1dfe99e90 100644 --- a/textfield/harness.ts +++ b/textfield/harness.ts @@ -91,7 +91,7 @@ export class TextFieldHarness extends Harness { } protected simulateInput( - element: HTMLInputElement, charactersToAppend: string, + element: HTMLInputElement|HTMLTextAreaElement, charactersToAppend: string, init?: InputEventInit) { element.value += charactersToAppend; if (!init) { @@ -106,8 +106,8 @@ export class TextFieldHarness extends Harness { } protected simulateDeletion( - element: HTMLInputElement, beginIndex?: number, endIndex?: number, - init?: InputEventInit) { + element: HTMLInputElement|HTMLTextAreaElement, beginIndex?: number, + endIndex?: number, init?: InputEventInit) { const deletedCharacters = element.value.slice(beginIndex, endIndex); element.value = element.value.substring(0, beginIndex ?? 0) + element.value.substring(endIndex ?? element.value.length); @@ -122,7 +122,8 @@ export class TextFieldHarness extends Harness { element.dispatchEvent(new InputEvent('input', init)); } - protected simulateChangeIfNeeded(element: HTMLInputElement) { + protected simulateChangeIfNeeded(element: HTMLInputElement| + HTMLTextAreaElement) { if (this.valueBeforeChange === element.value) { return; } @@ -133,6 +134,7 @@ export class TextFieldHarness extends Harness { protected override async getInteractiveElement() { await this.element.updateComplete; - return this.element.renderRoot.querySelector('input')!; + return this.element.renderRoot.querySelector('.input') as HTMLInputElement | + HTMLTextAreaElement; } } diff --git a/textfield/lib/_input.scss b/textfield/lib/_input.scss index 2703cf4362..cefabe277e 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 2b6e8fd7cc..adca102cf9 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 587e646ab6..02322235e9 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}));