/** * @license * Copyright 2021 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import {LitElement, PropertyValues, html, nothing} from 'lit'; import {property, query, queryAssignedElements, state} from 'lit/decorators.js'; import {classMap} from 'lit/directives/class-map.js'; import {live} from 'lit/directives/live.js'; import {StyleInfo, styleMap} from 'lit/directives/style-map.js'; import {StaticValue, html as staticHtml} from 'lit/static-html.js'; import {Field} from '../../field/internal/field.js'; import {ARIAMixinStrict} from '../../internal/aria/aria.js'; import {mixinDelegatesAria} from '../../internal/aria/delegate.js'; import {stringConverter} from '../../internal/controller/string-converter.js'; import {redispatchEvent} from '../../internal/events/redispatch-event.js'; import { createValidator, getValidityAnchor, mixinConstraintValidation, } from '../../labs/behaviors/constraint-validation.js'; import {mixinElementInternals} from '../../labs/behaviors/element-internals.js'; import { getFormValue, mixinFormAssociated, } from '../../labs/behaviors/form-associated.js'; import { mixinOnReportValidity, onReportValidity, } from '../../labs/behaviors/on-report-validity.js'; import {TextFieldValidator} from '../../labs/behaviors/validators/text-field-validator.js'; import {Validator} from '../../labs/behaviors/validators/validator.js'; /** * Input types that are compatible with the text field. */ export type TextFieldType = | 'email' | 'number' | 'password' | 'search' | 'tel' | 'text' | 'url' | 'textarea'; /** * Input types that are not fully supported for the text field. */ export type UnsupportedTextFieldType = | 'color' | 'date' | 'datetime-local' | 'file' | 'month' | 'time' | 'week'; /** * Input types that are incompatible with the text field. */ export type InvalidTextFieldType = | 'button' | 'checkbox' | 'hidden' | 'image' | 'radio' | 'range' | 'reset' | 'submit'; // Separate variable needed for closure. const textFieldBaseClass = mixinDelegatesAria( mixinOnReportValidity( mixinConstraintValidation( mixinFormAssociated(mixinElementInternals(LitElement)), ), ), ); /** * A text field component. * * @fires select {Event} The native `select` event on * [``](https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/select_event) * --bubbles * @fires change {Event} The native `change` event on * [``](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/change_event) * --bubbles * @fires input {InputEvent} The native `input` event on * [``](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event) * --bubbles --composed */ export abstract class TextField extends textFieldBaseClass { /** @nocollapse */ static override shadowRootOptions: ShadowRootInit = { ...LitElement.shadowRootOptions, delegatesFocus: true, }; /** * Gets or sets whether or not the text field is in a visually invalid state. * * This error state overrides the error state controlled by * `reportValidity()`. */ @property({type: Boolean, reflect: true}) error = false; /** * The error message that replaces supporting text when `error` is true. If * `errorText` is an empty string, then the supporting text will continue to * show. * * This error message overrides the error message displayed by * `reportValidity()`. */ @property({attribute: 'error-text'}) errorText = ''; /** * The floating Material label of the textfield component. It informs the user * about what information is requested for a text field. It is aligned with * the input text, is always visible, and it floats when focused or when text * is entered into the textfield. This label also sets accessibilty labels, * but the accessible label is overriden by `aria-label`. * * Learn more about floating labels from the Material Design guidelines: * https://m3.material.io/components/text-fields/guidelines */ @property() label = ''; /** * Disables the asterisk on the floating label, when the text field is * required. */ @property({type: Boolean, attribute: 'no-asterisk'}) noAsterisk = false; /** * Indicates that the user must specify a value for the input before the * owning form can be submitted and will render an error state when * `reportValidity()` is invoked when value is empty. Additionally the * floating label will render an asterisk `"*"` when true. * * https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/required */ @property({type: Boolean, reflect: true}) required = false; /** * The current value of the text field. It is always a string. */ @property() value = ''; /** * An optional prefix to display before the input value. */ @property({attribute: 'prefix-text'}) prefixText = ''; /** * An optional suffix to display after the input value. */ @property({attribute: 'suffix-text'}) suffixText = ''; /** * Whether or not the text field has a leading icon. Used for SSR. */ @property({type: Boolean, attribute: 'has-leading-icon'}) hasLeadingIcon = false; /** * Whether or not the text field has a trailing icon. Used for SSR. */ @property({type: Boolean, attribute: 'has-trailing-icon'}) hasTrailingIcon = false; /** * Conveys additional information below the text field, such as how it should * be used. */ @property({attribute: 'supporting-text'}) supportingText = ''; /** * Override the input text CSS `direction`. Useful for RTL languages that use * LTR notation for fractions. */ @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 number of cols to display for a `type="textarea"` text field. * Defaults to 20. */ @property({type: Number}) cols = 20; // properties @property({reflect: true}) override inputMode = ''; /** * Defines the greatest value in the range of permitted values. * * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#max */ @property() max = ''; /** * The maximum number of characters a user can enter into the text field. Set * to -1 for none. * * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#maxlength */ @property({type: Number}) maxLength = -1; /** * Defines the most negative value in the range of permitted values. * * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#min */ @property() min = ''; /** * The minimum number of characters a user can enter into the text field. Set * to -1 for none. * * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#minlength */ @property({type: Number}) minLength = -1; /** * When true, hide the spinner for `type="number"` text fields. */ @property({type: Boolean, attribute: 'no-spinner'}) noSpinner = false; /** * A regular expression that the text field's value must match to pass * constraint validation. * * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#pattern */ @property() pattern = ''; /** * Defines the text displayed in the textfield when it has no value. Provides * a brief hint to the user as to the expected type of data that should be * entered into the control. Unlike `label`, the placeholder is not visible * and does not float when the textfield has a value. * * https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/placeholder */ @property({reflect: true, converter: stringConverter}) placeholder = ''; /** * Indicates whether or not a user should be able to edit the text field's * value. * * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#readonly */ @property({type: Boolean, reflect: true}) readOnly = false; /** * Indicates that input accepts multiple email addresses. * * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email#multiple */ @property({type: Boolean, reflect: true}) multiple = false; /** * Gets or sets the direction in which selection occurred. */ get selectionDirection() { return this.getInputOrTextarea().selectionDirection; } set selectionDirection(value: 'forward' | 'backward' | 'none' | null) { this.getInputOrTextarea().selectionDirection = value; } /** * Gets or sets the end position or offset of a text selection. */ get selectionEnd() { return this.getInputOrTextarea().selectionEnd; } set selectionEnd(value: number | null) { this.getInputOrTextarea().selectionEnd = value; } /** * Gets or sets the starting position or offset of a text selection. */ get selectionStart() { return this.getInputOrTextarea().selectionStart; } set selectionStart(value: number | null) { this.getInputOrTextarea().selectionStart = value; } /** * Returns or sets the element's step attribute, which works with min and max * to limit the increments at which a numeric or date-time value can be set. * * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#step */ @property() step = ''; /** * The `` type to use, defaults to "text". The type greatly changes how * the text field behaves. * * Text fields support a limited number of `` types: * * - text * - textarea * - email * - number * - password * - search * - tel * - url * * See * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#input_types * for more details on each input type. */ @property({reflect: true}) type: TextFieldType | UnsupportedTextFieldType = 'text'; /** * Describes what, if any, type of autocomplete functionality the input * should provide. * * https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete */ @property({reflect: true}) autocomplete = ''; /** * The text field's value as a number. */ get valueAsNumber() { const input = this.getInput(); if (!input) { return NaN; } return input.valueAsNumber; } set valueAsNumber(value: number) { const input = this.getInput(); if (!input) { return; } input.valueAsNumber = value; this.value = input.value; } /** * The text field's value as a Date. */ get valueAsDate() { const input = this.getInput(); if (!input) { return null; } return input.valueAsDate; } set valueAsDate(value: Date | null) { const input = this.getInput(); if (!input) { return; } input.valueAsDate = value; this.value = input.value; } protected abstract readonly fieldTag: StaticValue; /** * Returns true when the text field has been interacted with. Native * validation errors only display in response to user interactions. */ @state() private dirty = false; @state() private focused = false; /** * Whether or not a native error has been reported via `reportValidity()`. */ @state() private nativeError = false; /** * The validation message displayed from a native error via * `reportValidity()`. */ @state() private nativeErrorText = ''; private get hasError() { return this.error || this.nativeError; } @query('.input') private readonly inputOrTextarea!: | HTMLInputElement | HTMLTextAreaElement | null; @query('.field') private readonly field!: Field | null; @queryAssignedElements({slot: 'leading-icon'}) private readonly leadingIcons!: Element[]; @queryAssignedElements({slot: 'trailing-icon'}) private readonly trailingIcons!: Element[]; /** * Selects all the text in the text field. * * https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/select */ select() { this.getInputOrTextarea().select(); } /** * Replaces a range of text with a new string. * * https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/setRangeText */ setRangeText(replacement: string): void; setRangeText( replacement: string, start: number, end: number, selectionMode?: SelectionMode, ): void; 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.getInputOrTextarea().setRangeText( ...(args as Parameters), ); this.value = this.getInputOrTextarea().value; } /** * Sets the start and end positions of a selection in the text field. * * https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/setSelectionRange * * @param start The offset into the text field for the start of the selection. * @param end The offset into the text field for the end of the selection. * @param direction The direction in which the selection is performed. */ setSelectionRange( start: number | null, end: number | null, direction?: 'forward' | 'backward' | 'none', ) { this.getInputOrTextarea().setSelectionRange(start, end, direction); } /** * Shows the browser picker for an input element of type "date", "time", etc. * * For a full list of supported types, see: * https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/showPicker#browser_compatibility * * https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/showPicker */ showPicker() { const input = this.getInput(); if (!input) { return; } input.showPicker(); } /** * Decrements the value of a numeric type text field by `step` or `n` `step` * number of times. * * https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/stepDown * * @param stepDecrement The number of steps to decrement, defaults to 1. */ stepDown(stepDecrement?: number) { const input = this.getInput(); if (!input) { return; } input.stepDown(stepDecrement); this.value = input.value; } /** * Increments the value of a numeric type text field by `step` or `n` `step` * number of times. * * https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/stepUp * * @param stepIncrement The number of steps to increment, defaults to 1. */ stepUp(stepIncrement?: number) { const input = this.getInput(); if (!input) { return; } input.stepUp(stepIncrement); this.value = input.value; } /** * Reset the text field to its default value. */ reset() { this.dirty = false; this.value = this.getAttribute('value') ?? ''; this.nativeError = false; this.nativeErrorText = ''; } override attributeChangedCallback( attribute: string, newValue: string | null, oldValue: string | null, ) { if (attribute === 'value' && this.dirty) { // After user input, changing the value attribute no longer updates the // text field's value (until reset). This matches native behavior. return; } super.attributeChangedCallback(attribute, newValue, oldValue); } protected override render() { const classes = { 'disabled': this.disabled, 'error': !this.disabled && this.hasError, 'textarea': this.type === 'textarea', 'no-spinner': this.noSpinner, }; return html` ${this.renderField()} `; } protected override updated(changedProperties: PropertyValues) { // Keep changedProperties arg so that subclasses may call it // If a property such as `type` changes and causes the internal // value to change without dispatching an event, re-sync it. const value = this.getInputOrTextarea().value; if (this.value !== value) { // Note this is typically inefficient in updated() since it schedules // another update. However, it is needed for the to fully render // before checking its value. this.value = value; } } private renderField() { 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} ?no-asterisk=${this.noAsterisk} max=${this.maxLength} ?populated=${!!this.value} ?required=${this.required} ?resizable=${this.type === 'textarea'} supporting-text=${this.supportingText} > ${this.renderLeadingIcon()} ${this.renderInputOrTextarea()} ${this.renderTrailingIcon()}
`; } private renderLeadingIcon() { return html` `; } private renderTrailingIcon() { return html` `; } private renderInputOrTextarea() { const style: StyleInfo = {'direction': this.textDirection}; const ariaLabel = (this as ARIAMixinStrict).ariaLabel || this.label || nothing; // lit-anaylzer `autocomplete` types are too strict // tslint:disable-next-line:no-any const autocomplete = this.autocomplete as any; // These properties may be set to null if the attribute is removed, and // `null > -1` is incorrectly `true`. const hasMaxLength = (this.maxLength ?? -1) > -1; const hasMinLength = (this.minLength ?? -1) > -1; if (this.type === 'textarea') { return html` `; } const prefix = this.renderPrefix(); const suffix = this.renderSuffix(); // TODO(b/243805848): remove `as unknown as number` and `as any` once lit // analyzer is fixed // tslint:disable-next-line:no-any const inputMode = this.inputMode as any; return html`
${prefix} ${suffix}
`; } private renderPrefix() { return this.renderAffix(this.prefixText, /* isSuffix */ false); } private renderSuffix() { return this.renderAffix(this.suffixText, /* isSuffix */ true); } private renderAffix(text: string, isSuffix: boolean) { if (!text) { return nothing; } const classes = { 'suffix': isSuffix, 'prefix': !isSuffix, }; return html`${text}`; } private getErrorText() { return this.error ? this.errorText : this.nativeErrorText; } private handleFocusChange() { // When calling focus() or reportValidity() during change, it's possible // for blur to be called after the new focus event. Rather than set // `this.focused` to true/false on focus/blur, we always set it to whether // or not the input itself is focused. this.focused = this.inputOrTextarea?.matches(':focus') ?? false; } private handleInput(event: InputEvent) { this.dirty = true; this.value = (event.target as HTMLInputElement).value; } private redispatchEvent(event: Event) { redispatchEvent(this, event); } private getInputOrTextarea() { if (!this.inputOrTextarea) { // If the input is not yet defined, synchronously render. // e.g. // const textField = document.createElement('md-outlined-text-field'); // document.body.appendChild(textField); // textField.focus(); // synchronously render this.connectedCallback(); this.scheduleUpdate(); } if (this.isUpdatePending) { // If there are pending updates, synchronously perform them. This ensures // that constraint validation properties (like `required`) are synced // before interacting with input APIs that depend on them. this.scheduleUpdate(); } return this.inputOrTextarea!; } private getInput() { if (this.type === 'textarea') { return null; } return this.getInputOrTextarea() as HTMLInputElement; } private handleIconChange() { this.hasLeadingIcon = this.leadingIcons.length > 0; this.hasTrailingIcon = this.trailingIcons.length > 0; } // Writable mixin properties for lit-html binding, needed for lit-analyzer declare disabled: boolean; declare name: string; override [getFormValue]() { return this.value; } override formResetCallback() { this.reset(); } override formStateRestoreCallback(state: string) { this.value = state; } override focus() { // Required for the case that the user slots a focusable element into the // leading icon slot such as an iconbutton due to how delegatesFocus works. this.getInputOrTextarea().focus(); } override [createValidator](): Validator { return new TextFieldValidator(() => ({ state: this, renderedControl: this.inputOrTextarea, })); } override [getValidityAnchor](): HTMLElement | null { return this.inputOrTextarea; } override [onReportValidity](invalidEvent: Event | null) { // Prevent default pop-up behavior. invalidEvent?.preventDefault(); const prevMessage = this.getErrorText(); this.nativeError = !!invalidEvent; this.nativeErrorText = this.validationMessage; if (prevMessage === this.getErrorText()) { this.field?.reannounceError(); } } }