import type { Getter } from "melt"; import { extract } from "./extract.svelte.js"; import { useResizeObserver, watch } from "runed"; import { onDestroy, tick } from "svelte"; export interface TextareaAutosizeOptions { /** Textarea element to autosize. */ element: Getter; /** Textarea content. */ input: Getter; /** Function called when the textarea size changes. */ onResize?: () => void; /** * Specify the style property that will be used to manipulate height. Can be `height | minHeight`. * @default `height` **/ styleProp?: "height" | "minHeight"; /** * Maximum height of the textarea before enabling scrolling. * @default `undefined` (no maximum) */ maxHeight?: number; } export class TextareaAutosize { #options: TextareaAutosizeOptions; #resizeTimeout: number | null = null; #hiddenTextarea: HTMLTextAreaElement | null = null; element = $derived.by(() => extract(this.#options.element)); input = $derived.by(() => extract(this.#options.input)); styleProp = $derived.by(() => extract(this.#options.styleProp, "height")); maxHeight = $derived.by(() => extract(this.#options.maxHeight, undefined)); textareaHeight = $state(0); textareaOldWidth = $state(0); constructor(options: TextareaAutosizeOptions) { this.#options = options; // Create hidden textarea for measurements this.#createHiddenTextarea(); watch([() => this.input, () => this.element], () => { tick().then(() => this.triggerResize()); }); watch( () => this.textareaHeight, () => options?.onResize?.() ); useResizeObserver( () => this.element, ([entry]) => { if (!entry) return; const { contentRect } = entry; if (this.textareaOldWidth === contentRect.width) return; this.textareaOldWidth = contentRect.width; this.triggerResize(); } ); onDestroy(() => { // Clean up if (this.#hiddenTextarea) { this.#hiddenTextarea.remove(); this.#hiddenTextarea = null; } if (this.#resizeTimeout) { window.cancelAnimationFrame(this.#resizeTimeout); this.#resizeTimeout = null; } }); } #createHiddenTextarea() { // Create a hidden textarea that will be used for measurements // This avoids layout shifts caused by manipulating the actual textarea if (typeof window === "undefined") return; this.#hiddenTextarea = document.createElement("textarea"); const style = this.#hiddenTextarea.style; // Make it invisible but keep same text layout properties style.visibility = "hidden"; style.position = "absolute"; style.overflow = "hidden"; style.height = "0"; style.top = "0"; style.left = "-9999px"; document.body.appendChild(this.#hiddenTextarea); } #copyStyles() { if (!this.element || !this.#hiddenTextarea) return; const computed = window.getComputedStyle(this.element); // Copy all the styles that affect text layout const stylesToCopy = [ "box-sizing", "width", "padding-top", "padding-right", "padding-bottom", "padding-left", "border-top-width", "border-right-width", "border-bottom-width", "border-left-width", "font-family", "font-size", "font-weight", "font-style", "letter-spacing", "text-indent", "text-transform", "line-height", "word-spacing", "word-wrap", "word-break", "white-space", ]; stylesToCopy.forEach(style => { this.#hiddenTextarea!.style.setProperty(style, computed.getPropertyValue(style)); }); // Ensure the width matches exactly this.#hiddenTextarea.style.width = `${this.element.clientWidth}px`; } triggerResize = () => { if (!this.element || !this.#hiddenTextarea) return; // Copy current styles and content to hidden textarea this.#copyStyles(); this.#hiddenTextarea.value = this.input || ""; // Measure the hidden textarea const scrollHeight = this.#hiddenTextarea.scrollHeight; // Apply the height, respecting maxHeight if set let newHeight = scrollHeight; if (this.maxHeight && newHeight > this.maxHeight) { newHeight = this.maxHeight; this.element.style.overflowY = "auto"; } else { this.element.style.overflowY = "hidden"; } // Only update if height actually changed if (this.textareaHeight !== newHeight) { this.textareaHeight = newHeight; this.element.style[this.styleProp] = `${newHeight}px`; } }; }