@material/web
Version:
Material web components
769 lines • 25.9 kB
JavaScript
/**
* @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" =${this.handleIconChange}></slot>
</span>
`;
}
renderTrailingIcon() {
return html `
<span class="icon trailing" slot="end">
<slot name="trailing-icon" =${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)}
=${this.handleChange}
=${this.handleFocusin}
=${this.handleFocusout}
=${this.handleInput}
=${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)}
=${this.redispatchEvent}
=${this.handleFocusin}
=${this.handleFocusout}
=${this.handleInput}
=${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