/**
* @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()}
${this.fieldTag}>`;
}
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();
}
}
}