UNPKG

@material/web

Version:
769 lines 25.9 kB
/** * @license * Copyright 2021 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { __decorate } from "tslib"; import { html, LitElement, 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 { styleMap } from 'lit/directives/style-map.js'; import { html as staticHtml } from 'lit/static-html.js'; import { requestUpdateOnAriaChange } from '../../internal/aria/delegate.js'; import { redispatchEvent } from '../../internal/controller/events.js'; import { stringConverter } from '../../internal/controller/string-converter.js'; /** * A text field component. */ export class TextField extends LitElement { constructor() { super(...arguments); this.disabled = false; /** * 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()`. */ this.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()`. */ this.errorText = ''; this.label = ''; this.required = false; /** * The current value of the text field. It is always a string. */ this.value = ''; /** * An optional prefix to display before the input value. */ this.prefixText = ''; /** * An optional suffix to display after the input value. */ this.suffixText = ''; /** * Whether or not the text field has a leading icon. Used for SSR. */ this.hasLeadingIcon = false; /** * Whether or not the text field has a trailing icon. Used for SSR. */ this.hasTrailingIcon = false; /** * Conveys additional information below the text field, such as how it should * be used. */ this.supportingText = ''; /** * Override the input text CSS `direction`. Useful for RTL languages that use * LTR notation for fractions. */ this.textDirection = ''; /** * The number of rows to display for a `type="textarea"` text field. * Defaults to 2. */ this.rows = 2; // <input> properties this.inputMode = ''; /** * Defines the greatest value in the range of permitted values. * * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#max */ this.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 */ this.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 */ this.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 */ this.minLength = -1; /** * 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 */ this.pattern = ''; this.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 */ this.readOnly = false; /** * Indicates that input accepts multiple email addresses. * * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email#multiple */ this.multiple = false; /** * 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 */ this.step = ''; /** * The `<input>` type to use, defaults to "text". The type greatly changes how * the text field behaves. * * Text fields support a limited number of `<input>` 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. */ this.type = 'text'; /** * Describes what, if any, type of autocomplete functionality the input * should provide. * * https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete */ this.autocomplete = ''; /** * Returns true when the text field has been interacted with. Native * validation errors only display in response to user interactions. */ this.dirty = false; this.focused = false; /** * Whether or not a native error has been reported via `reportValidity()`. */ this.nativeError = false; /** * The validation message displayed from a native error via * `reportValidity()`. */ this.nativeErrorText = ''; // Needed for Safari, see https://bugs.webkit.org/show_bug.cgi?id=261432 // Replace with this.internals.validity.customError when resolved. this.hasCustomValidityError = false; this.internals = this /* needed for closure */.attachInternals(); } /** * The associated form element with which this element's value will submit. */ get form() { return this.internals.form; } /** * The labels this element is associated with. */ get labels() { return this.internals.labels; } /** * The HTML name to use in form submission. */ get name() { return this.getAttribute('name') ?? ''; } set name(name) { this.setAttribute('name', name); } /** * Gets or sets the direction in which selection occurred. */ get selectionDirection() { return this.getInputOrTextarea().selectionDirection; } set selectionDirection(value) { 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) { 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) { this.getInputOrTextarea().selectionStart = value; } /** * Returns the text field's validation error message. * * https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation */ get validationMessage() { this.syncValidity(); return this.internals.validationMessage; } /** * Returns a `ValidityState` object that represents the validity states of the * text field. * * https://developer.mozilla.org/en-US/docs/Web/API/ValidityState */ get validity() { this.syncValidity(); return this.internals.validity; } /** * The text field's value as a number. */ get valueAsNumber() { const input = this.getInput(); if (!input) { return NaN; } return input.valueAsNumber; } set valueAsNumber(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() { const input = this.getInput(); if (!input) { return null; } return input.valueAsDate; } set valueAsDate(value) { const input = this.getInput(); if (!input) { return; } input.valueAsDate = value; this.value = input.value; } /** * Returns whether an element will successfully validate based on forms * validation rules and constraints. * * https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals/willValidate */ get willValidate() { this.syncValidity(); return this.internals.willValidate; } get hasError() { return this.error || this.nativeError; } /** * Checks the text field's native validation and returns whether or not the * element is valid. * * If invalid, this method will dispatch the `invalid` event. * * https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/checkValidity * * @return true if the text field is valid, or false if not. */ checkValidity() { this.syncValidity(); return this.internals.checkValidity(); } /** * Checks the text field's native validation and returns whether or not the * element is valid. * * If invalid, this method will dispatch the `invalid` event. * * This method will display or clear an error text message equal to the text * field's `validationMessage`, unless the invalid event is canceled. * * Use `setCustomValidity()` to customize the `validationMessage`. * * This method can also be used to re-announce error messages to screen * readers. * * https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/reportValidity * * @return true if the text field is valid, or false if not. */ reportValidity() { let invalidEvent; this.addEventListener('invalid', event => { invalidEvent = event; }, { once: true }); const valid = this.checkValidity(); if (invalidEvent?.defaultPrevented) { return valid; } const prevMessage = this.getErrorText(); this.nativeError = !valid; this.nativeErrorText = this.validationMessage; if (prevMessage === this.getErrorText()) { this.field?.reannounceError(); } return valid; } /** * Selects all the text in the text field. * * https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/select */ select() { this.getInputOrTextarea().select(); } /** * Sets a custom validation error message for the text field. Use this for * custom error message. * * When the error is not an empty string, the text field is considered invalid * and `validity.customError` will be true. * * https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/setCustomValidity * * @param error The error message to display. */ setCustomValidity(error) { this.hasCustomValidityError = !!error; this.internals.setValidity({ customError: !!error }, error, this.getInputOrTextarea()); } setRangeText(...args) { // 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); 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, end, direction) { this.getInputOrTextarea().setSelectionRange(start, end, direction); } /** * 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) { 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) { 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 = ''; } attributeChangedCallback(attribute, newValue, oldValue) { 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 <input> behavior. return; } super.attributeChangedCallback(attribute, newValue, oldValue); } render() { const classes = { 'disabled': this.disabled, 'error': !this.disabled && this.hasError, 'textarea': this.type === 'textarea', }; return html ` <span class="text-field ${classMap(classes)}"> ${this.renderField()} </span> `; } updated(changedProperties) { // Keep changedProperties arg so that subclasses may call it // If a property such as `type` changes and causes the internal <input> // 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 <input> to fully render // before checking its value. this.value = value; } this.internals.setFormValue(value); // Sync validity when properties change, since validation properties may // have changed. this.syncValidity(); } 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} max=${this.maxLength} ?populated=${!!this.value} ?required=${this.required} ?resizable=${this.type === 'textarea'} supporting-text=${this.supportingText} > ${this.renderLeadingIcon()} ${this.renderInputOrTextarea()} ${this.renderTrailingIcon()} <div id="description" slot="aria-describedby"></div> </${this.fieldTag}>`; } renderLeadingIcon() { return html ` <span class="icon leading" slot="start"> <slot name="leading-icon" @slotchange=${this.handleIconChange}></slot> </span> `; } renderTrailingIcon() { return html ` <span class="icon trailing" slot="end"> <slot name="trailing-icon" @slotchange=${this.handleIconChange}></slot> </span> `; } renderInputOrTextarea() { const style = { direction: this.textDirection }; const ariaLabel = this.ariaLabel || this.label || nothing; // lit-anaylzer `autocomplete` types are too strict // tslint:disable-next-line:no-any const autocomplete = this.autocomplete; if (this.type === 'textarea') { return html ` <textarea class="input" style=${styleMap(style)} aria-describedby="description" aria-invalid=${this.hasError} aria-label=${ariaLabel} autocomplete=${autocomplete || nothing} ?disabled=${this.disabled} maxlength=${this.maxLength > -1 ? this.maxLength : nothing} minlength=${this.minLength > -1 ? this.minLength : nothing} placeholder=${this.placeholder || nothing} ?readonly=${this.readOnly} ?required=${this.required} rows=${this.rows} .value=${live(this.value)} @change=${this.handleChange} @focusin=${this.handleFocusin} @focusout=${this.handleFocusout} @input=${this.handleInput} @select=${this.redispatchEvent} ></textarea> `; } 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; return html ` <div class="input-wrapper"> ${prefix} <input class="input" style=${styleMap(style)} aria-describedby="description" aria-invalid=${this.hasError} aria-label=${ariaLabel} autocomplete=${autocomplete || nothing} ?disabled=${this.disabled} inputmode=${inputMode || nothing} max=${(this.max || nothing)} maxlength=${this.maxLength > -1 ? this.maxLength : nothing} min=${(this.min || nothing)} minlength=${this.minLength > -1 ? this.minLength : nothing} pattern=${this.pattern || nothing} placeholder=${this.placeholder || nothing} ?readonly=${this.readOnly} ?required=${this.required} ?multiple=${this.multiple} step=${(this.step || nothing)} type=${this.type} .value=${live(this.value)} @change=${this.redispatchEvent} @focusin=${this.handleFocusin} @focusout=${this.handleFocusout} @input=${this.handleInput} @select=${this.redispatchEvent} > ${suffix} </div> `; } renderPrefix() { return this.renderAffix(this.prefixText, /* isSuffix */ false); } renderSuffix() { return this.renderAffix(this.suffixText, /* isSuffix */ true); } renderAffix(text, isSuffix) { if (!text) { return nothing; } const classes = { 'suffix': isSuffix, 'prefix': !isSuffix, }; return html `<span class="${classMap(classes)}">${text}</span>`; } getErrorText() { return this.error ? this.errorText : this.nativeErrorText; } handleFocusin() { this.focused = true; } handleFocusout() { this.focused = false; } handleInput(event) { this.dirty = true; this.value = event.target.value; // Sync validity so that clients can check validity on input. this.syncValidity(); } handleChange(event) { // Sync validity so that clients can check validity on change. this.syncValidity(); this.redispatchEvent(event); } redispatchEvent(event) { redispatchEvent(this, event); } 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; } getInput() { if (this.type === 'textarea') { return null; } return this.getInputOrTextarea(); } syncValidity() { // Sync the internal <input>'s validity and the host's ElementInternals // validity. We do this to re-use native `<input>` validation messages. const input = this.getInputOrTextarea(); if (this.hasCustomValidityError) { input.setCustomValidity(this.internals.validationMessage); } else { input.setCustomValidity(''); } this.internals.setValidity(input.validity, input.validationMessage, this.getInputOrTextarea()); } handleIconChange() { this.hasLeadingIcon = this.leadingIcons.length > 0; this.hasTrailingIcon = this.trailingIcons.length > 0; } /** @private */ formResetCallback() { this.reset(); } /** @private */ formStateRestoreCallback(state) { this.value = state; } 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(); } } (() => { requestUpdateOnAriaChange(TextField); })(); /** @nocollapse */ TextField.shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }; /** @nocollapse */ TextField.formAssociated = true; __decorate([ property({ type: Boolean, reflect: true }) ], TextField.prototype, "disabled", void 0); __decorate([ property({ type: Boolean, reflect: true }) ], TextField.prototype, "error", void 0); __decorate([ property({ attribute: 'error-text' }) ], TextField.prototype, "errorText", void 0); __decorate([ property() ], TextField.prototype, "label", void 0); __decorate([ property({ type: Boolean, reflect: true }) ], TextField.prototype, "required", void 0); __decorate([ property() ], TextField.prototype, "value", void 0); __decorate([ property({ attribute: 'prefix-text' }) ], TextField.prototype, "prefixText", void 0); __decorate([ property({ attribute: 'suffix-text' }) ], TextField.prototype, "suffixText", void 0); __decorate([ property({ type: Boolean, attribute: 'has-leading-icon' }) ], TextField.prototype, "hasLeadingIcon", void 0); __decorate([ property({ type: Boolean, attribute: 'has-trailing-icon' }) ], TextField.prototype, "hasTrailingIcon", void 0); __decorate([ property({ attribute: 'supporting-text' }) ], TextField.prototype, "supportingText", void 0); __decorate([ property({ attribute: 'text-direction' }) ], TextField.prototype, "textDirection", void 0); __decorate([ property({ type: Number }) ], TextField.prototype, "rows", void 0); __decorate([ property({ reflect: true }) ], TextField.prototype, "inputMode", void 0); __decorate([ property() ], TextField.prototype, "max", void 0); __decorate([ property({ type: Number }) ], TextField.prototype, "maxLength", void 0); __decorate([ property() ], TextField.prototype, "min", void 0); __decorate([ property({ type: Number }) ], TextField.prototype, "minLength", void 0); __decorate([ property() ], TextField.prototype, "pattern", void 0); __decorate([ property({ reflect: true, converter: stringConverter }) ], TextField.prototype, "placeholder", void 0); __decorate([ property({ type: Boolean, reflect: true }) ], TextField.prototype, "readOnly", void 0); __decorate([ property({ type: Boolean, reflect: true }) ], TextField.prototype, "multiple", void 0); __decorate([ property() ], TextField.prototype, "step", void 0); __decorate([ property({ reflect: true }) ], TextField.prototype, "type", void 0); __decorate([ property({ reflect: true }) ], TextField.prototype, "autocomplete", void 0); __decorate([ state() ], TextField.prototype, "dirty", void 0); __decorate([ state() ], TextField.prototype, "focused", void 0); __decorate([ state() ], TextField.prototype, "nativeError", void 0); __decorate([ state() ], TextField.prototype, "nativeErrorText", void 0); __decorate([ query('.input') ], TextField.prototype, "inputOrTextarea", void 0); __decorate([ query('.field') ], TextField.prototype, "field", void 0); __decorate([ queryAssignedElements({ slot: 'leading-icon' }) ], TextField.prototype, "leadingIcons", void 0); __decorate([ queryAssignedElements({ slot: 'trailing-icon' }) ], TextField.prototype, "trailingIcons", void 0); //# sourceMappingURL=text-field.js.map