UNPKG

@vaadin/text-area

Version:
300 lines (256 loc) 9.88 kB
/** * @license * Copyright (c) 2021 - 2026 Vaadin Ltd. * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ */ import { ResizeMixin } from '@vaadin/component-base/src/resize-mixin.js'; import { InputFieldMixin } from '@vaadin/field-base/src/input-field-mixin.js'; import { LabelledInputController } from '@vaadin/field-base/src/labelled-input-controller.js'; import { TextAreaController } from '@vaadin/field-base/src/text-area-controller.js'; /** * A mixin providing common text area functionality. * * @polymerMixin * @mixes InputFieldMixin * @mixes ResizeMixin */ export const TextAreaMixin = (superClass) => class TextAreaMixinClass extends ResizeMixin(InputFieldMixin(superClass)) { static get properties() { return { /** * Maximum number of characters (in Unicode code points) that the user can enter. */ maxlength: { type: Number, }, /** * Minimum number of characters (in Unicode code points) that the user can enter. */ minlength: { type: Number, }, /** * A regular expression that the value is checked against. * The pattern must match the entire value, not just some subset. */ pattern: { type: String, }, /** * Minimum number of rows to show. Default is two rows. * * When using a custom slotted textarea, the minimum number of rows are not applied for backwards compatibility. * * @attr {number} min-rows */ minRows: { type: Number, value: 2, observer: '__minRowsChanged', }, /** * Maximum number of rows to expand to before the text area starts scrolling. This effectively sets a max-height * on the `input-field` part. By default, it is not set, and the text area grows with the content without * constraints. * @attr {number} max-rows */ maxRows: { type: Number, }, }; } static get delegateAttrs() { return [...super.delegateAttrs, 'maxlength', 'minlength', 'pattern']; } static get constraints() { return [...super.constraints, 'maxlength', 'minlength', 'pattern']; } static get observers() { return ['__updateMinHeight(minRows, inputElement)', '__updateMaxHeight(maxRows, inputElement, _inputField)']; } /** * Used by `InputControlMixin` as a reference to the clear button element. * @protected */ get clearElement() { return this.$.clearButton; } /** * @protected * @override */ _onResize() { this._updateHeight(); this.__scrollPositionUpdated(); } /** @protected */ _onScroll() { this.__scrollPositionUpdated(); } /** @protected */ ready() { super.ready(); this.__textAreaController = new TextAreaController(this, (input) => { this._setInputElement(input); this._setFocusElement(input); this.stateTarget = input; this.ariaTarget = input; }); this.addController(this.__textAreaController); this.addController(new LabelledInputController(this.inputElement, this._labelController)); this._inputField = this.shadowRoot.querySelector('[part=input-field]'); // Wheel scrolling results in async scroll events. Preventing the wheel // event, scrolling manually and then synchronously updating the scroll position CSS variable // allows us to avoid some jumpy behavior that would occur on wheel otherwise. this._inputField.addEventListener('wheel', (e) => { const scrollTopBefore = this._inputField.scrollTop; this._inputField.scrollTop += e.deltaY; if (scrollTopBefore !== this._inputField.scrollTop) { e.preventDefault(); this.__scrollPositionUpdated(); } }); this._updateHeight(); this.__scrollPositionUpdated(); } /** @private */ __scrollPositionUpdated() { this._inputField.style.setProperty('--_text-area-vertical-scroll-position', '0px'); this._inputField.style.setProperty('--_text-area-vertical-scroll-position', `${this._inputField.scrollTop}px`); } /** * @param {unknown} newVal * @param {unknown} oldVal * @protected * @override */ _valueChanged(newVal, oldVal) { super._valueChanged(newVal, oldVal); this._updateHeight(); } /** @private */ _updateHeight() { const input = this.inputElement; const inputField = this._inputField; if (!input || !inputField) { return; } const scrollTop = inputField.scrollTop; // Only clear the height when the content shortens to minimize scrollbar flickering. const valueLength = this.value ? this.value.length : 0; if (this._oldValueLength >= valueLength) { const inputFieldHeight = getComputedStyle(inputField).height; const inputWidth = getComputedStyle(input).width; // Temporarily fix the height of the wrapping input field container to prevent changing the browsers scroll // position while resetting the textareas height. If the textarea had a large height, then removing its height // will reset its height to the default of two rows. That might reduce the height of the page, and the // browser might adjust the scroll position before we can restore the measured height of the textarea. inputField.style.height = inputFieldHeight; // Fix the input element width so its scroll height isn't affected by host's disappearing scrollbars input.style.maxWidth = inputWidth; // Clear the height of the textarea to allow measuring a reduced scroll height input.style.alignSelf = 'flex-start'; input.style.height = 'auto'; } this._oldValueLength = valueLength; const inputHeight = input.scrollHeight; if (inputHeight > input.clientHeight) { input.style.height = `${inputHeight}px`; } // Restore input.style.removeProperty('max-width'); input.style.removeProperty('align-self'); inputField.style.removeProperty('height'); inputField.scrollTop = scrollTop; // Update max height in case this update was triggered by style changes // affecting line height, paddings or margins. this.__updateMaxHeight(this.maxRows); } /** @private */ __updateMinHeight(minRows) { if (!this.inputElement) { return; } // For minimum height, just set the number of rows on the native textarea, // which causes the input container to grow as well. // Do not override this on custom slotted textarea as number of rows may // have been configured there. if (this.inputElement === this.__textAreaController.defaultNode) { this.inputElement.rows = Math.max(minRows, 1); } } /** @private */ __updateMaxHeight(maxRows) { if (!this._inputField || !this.inputElement) { return; } if (maxRows) { // For maximum height, we need to constrain the height of the input // container to prevent it from growing further. For this we take the // line height of the native textarea times the number of rows, and add // other properties affecting the height of the input container. const inputStyle = getComputedStyle(this.inputElement); const inputFieldStyle = getComputedStyle(this._inputField); const lineHeight = parseFloat(inputStyle.lineHeight); const contentHeight = lineHeight * maxRows; const marginsAndPaddings = parseFloat(inputStyle.paddingTop) + parseFloat(inputStyle.paddingBottom) + parseFloat(inputStyle.marginTop) + parseFloat(inputStyle.marginBottom) + parseFloat(inputFieldStyle.borderTopWidth) + parseFloat(inputFieldStyle.borderBottomWidth) + parseFloat(inputFieldStyle.paddingTop) + parseFloat(inputFieldStyle.paddingBottom); const maxHeight = Math.ceil(contentHeight + marginsAndPaddings); this._inputField.style.setProperty('max-height', `${maxHeight}px`); } else { this._inputField.style.removeProperty('max-height'); } } /** * @private */ __minRowsChanged(minRows) { if (minRows < 1) { console.warn('<vaadin-text-area> minRows must be at least 1.'); } } /** * Scrolls the textarea to the start if it has a vertical scrollbar. */ scrollToStart() { this._inputField.scrollTop = 0; } /** * Scrolls the textarea to the end if it has a vertical scrollbar. */ scrollToEnd() { this._inputField.scrollTop = this._inputField.scrollHeight; } /** * Returns true if the current textarea value satisfies all constraints (if any). * @return {boolean} * @override */ checkValidity() { if (!super.checkValidity()) { return false; } // Native <textarea> does not support pattern attribute, so we have a custom logic // according to WHATWG spec for <input>, with tests inspired by web-platform-tests // https://html.spec.whatwg.org/multipage/input.html#the-pattern-attribute if (!this.pattern || !this.inputElement.value) { // Mark as valid if there is no pattern, or the value is empty return true; } try { const match = this.inputElement.value.match(this.pattern); return match ? match[0] === match.input : false; } catch (_) { // If the pattern can not be compiled, then report as valid return true; } } };