import { useResizeObserver, watch } from "runed"; import { onDestroy, tick } from "svelte"; import type { Attachment } from "svelte/attachments"; import { on } from "svelte/events"; import { extract } from "./extract.svelte.js"; export interface TextareaAutosizeOptions { /** 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 = $state<HTMLTextAreaElement>(); input = $state(""); 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`; } }; attachment: Attachment<HTMLTextAreaElement> = node => { this.element = node; this.input = node.value; // Detect programmatic changes const desc = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!; Object.defineProperty(node, "value", { get: desc.get, set: v => { const cleanup = $effect.root(() => { this.input = v; }); cleanup(); desc.set?.call(node, v); }, }); const removeListener = on(node, "input", _ => { this.input = node.value; }); return () => { removeListener(); this.element = undefined; }; }; }