UNPKG

tiny-essentials

Version:

Collection of small, essential scripts designed to be used across various projects. These simple utilities are crafted for speed, ease of use, and versatility.

231 lines (200 loc) 7.13 kB
'use strict'; /** * @typedef {Object} OnInputInfo * @property {number} breakLines - Total number of `\n` line breaks in the textarea value. * @property {number} height - Final calculated height (in pixels) applied to the textarea. * @property {number} scrollHeight - Internal scrollHeight before limiting. * @property {number} maxHeight - Maximum allowed height before scrolling is forced. * @property {number} lineHeight - Height of one line of text, computed from CSS. * @property {number} maxRows - Maximum number of visible rows allowed. * @property {number} rows - Effective number of visual rows being used. */ /** * A lightweight utility class that automatically adjusts the height of a `<textarea>` * element based on its content. It prevents scrollbars by expanding vertically as needed, * up to a configurable maximum number of visible rows. * * Features: * - Automatically resizes the textarea as the user types * - Prevents vertical scrollbars until a maximum row limit is reached * - Supports additional height padding * - Provides real-time callbacks for input and resize events * - Allows manual refresh and cleanup of behavior * * Ideal for chat inputs, note editors, or any form where dynamic space usage * is preferred without relying on scrollbars too early. * * @class * @beta */ class TinyTextarea { #lineHeight; #maxRows; #extraHeight; #lastKnownHeight = 0; #lastKnownRows = 0; /** @type {HTMLTextAreaElement} */ #textarea; /** * @type {((info: OnInputInfo) => void) | null} */ #onResize = null; /** * @type {((info: OnInputInfo) => void) | null} */ #onInput = null; /** * Returns the computed line height in pixels. * @returns {number} */ get lineHeight() { return this.#lineHeight; } /** * Returns the maximum number of rows allowed. * @returns {number} */ get maxRows() { return this.#maxRows - 1; } /** * Returns the additional height added to the textarea. * @returns {number} */ get extraHeight() { return this.#extraHeight; } /** * Returns the most recently applied height. * @returns {number} */ get currentHeight() { return this.#lastKnownHeight; } /** * Returns the most recently calculated row count. * @returns {number} */ get currentRows() { return this.#lastKnownRows; } /** * Returns the original textarea element managed by this instance. * @returns {HTMLTextAreaElement} */ get textarea() { return this.#textarea; } /** * Creates a new TinyTextarea instance. * * @param {HTMLTextAreaElement} textarea - The `<textarea>` element to enhance. * @param {Object} [options={}] - Optional configuration parameters. * @param {number} [options.maxRows] - Maximum number of visible rows before scrolling. * @param {number} [options.extraHeight] - Additional pixels to add to final height. * @param {(info: OnInputInfo) => void} [options.onResize] - Callback when the number of rows changes. * @param {(info: OnInputInfo) => void} [options.onInput] - Callback on every input event. * @throws {Error} If `textarea` is not a valid `<textarea>` element. * @throws {TypeError} If provided options are of invalid types. */ constructor(textarea, options = {}) { if (!(textarea instanceof HTMLTextAreaElement)) throw new Error('TinyTextarea: Provided element is not a <textarea>.'); if (typeof options !== 'object' || options === null) throw new TypeError('TinyTextarea: Options must be an object if provided.'); if ('maxRows' in options && typeof options.maxRows !== 'number') throw new TypeError('TinyTextarea: `maxRows` must be a number.'); if ('extraHeight' in options && typeof options.extraHeight !== 'number') throw new TypeError('TinyTextarea: `extraHeight` must be a number.'); if ('onResize' in options && typeof options.onResize !== 'function') throw new TypeError('TinyTextarea: `onResize` must be a function.'); if ('onInput' in options && typeof options.onInput !== 'function') throw new TypeError('TinyTextarea: `onInput` must be a function.'); this.#textarea = textarea; this.#maxRows = (options.maxRows ?? 5) + 1; this.#extraHeight = options.extraHeight ?? 0; this.#onResize = options.onResize ?? null; this.#onInput = options.onInput ?? null; this.#lineHeight = this.#getLineHeight(); textarea.style.overflowY = 'hidden'; textarea.style.resize = 'none'; this._handleInput = () => this.#resize(); textarea.addEventListener('input', this._handleInput); this.#resize(); } /** * Automatically resize the textarea based on its content and notify listeners. * Triggers `onResize` if the number of rows has changed. * Always triggers `onInput`. */ #resize() { this.#textarea.style.height = 'auto'; const style = window.getComputedStyle(this.#textarea); const paddingTop = parseFloat(style.paddingTop) || 0; const paddingBottom = parseFloat(style.paddingBottom) || 0; const breakLines = (this.#textarea.value.match(/\n/g) || []).length; const scrollHeight = this.#textarea.scrollHeight; const maxHeight = this.#lineHeight * this.#maxRows; const newHeight = Math.ceil( Math.min(scrollHeight, maxHeight) - paddingTop - paddingBottom + this.#extraHeight, ); // const rows = Math.round(newHeight / this.#lineHeight); const maxRows = this.#maxRows - 1; const rows = breakLines < maxRows ? breakLines + 1 : maxRows; this.#textarea.style.height = `${newHeight}px`; this.#textarea.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden'; this.#lastKnownHeight = newHeight; const info = { breakLines, rows, height: newHeight, scrollHeight, maxHeight, lineHeight: this.#lineHeight, maxRows, }; if (rows !== this.#lastKnownRows) { this.#lastKnownRows = rows; if (typeof this.#onResize === 'function') { this.#onResize({ ...info }); } } if (typeof this.#onInput === 'function') { this.#onInput(info); } } /** * Computes the current line height from the textarea's computed styles. * Falls back to `fontSize * 1.2` if lineHeight is not a number. * @returns {number} - The computed line height in pixels. */ #getLineHeight() { const style = window.getComputedStyle(this.#textarea); const line = parseFloat(style.lineHeight); if (!Number.isNaN(line)) return line; return parseFloat(style.fontSize) * 1.2; } /** * Returns the latest height and row count of the textarea. * @returns {{ height: number, rows: number }} - Last known resize state. */ getData() { return { rows: this.#lastKnownRows, height: this.#lastKnownHeight, }; } /** * Manually trigger a resize check. */ refresh() { this.#resize(); } /** * Cleans up internal listeners and disables dynamic behavior. */ destroy() { this.#textarea.removeEventListener('input', this._handleInput); } } module.exports = TinyTextarea;