Skip to content

Instantly share code, notes, and snippets.

@jennevdmeer
Last active July 28, 2022 09:13
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jennevdmeer/ed532d0cc44823f7ca17286bb3c89e20 to your computer and use it in GitHub Desktop.
Save jennevdmeer/ed532d0cc44823f7ca17286bb3c89e20 to your computer and use it in GitHub Desktop.
Little standalone TypeScript file to help with auto-sizing HTML text areas while typing.

Auto size text area

Little standalone TypeScript class to help with auto-sizing HTML text areas while typing.

Public API

  • constructor(options: AutoSizeTextAreaOptions = {})
  • load(element: HTMLElement) - Adds listers to all textareas in "element" and its children.
  • unload(element: HTMLElement) - Removes listeners from all textareas for "element".
  • loadTextarea(textArea: HTMLTextAreaElement) - Add listeners to specific textarea.
  • unloadTextarea(textArea: HTMLTextAreaElement) - Remove listeners from specific textarea.
  • observe(element: HTMLElement, loadExistingTextAreas: boolean = true) - Observer specific HTMLElement for addition/removal of textareas and automatically load/unload them (JavaScript DOM changes). If loadExistingTextAreas is true the initial content will also be scanned for textareas and loaded.
  • disconnect(element: HTMLElement|MutationObserver)

Options

interface AutoSizeTextAreaOptions {
    observe?: string[];
    loadExistingTextAreas?: boolean;
    textAreaSelector?: string;

    minLines?: number;
    minHeight?: number;
    maxLines?: number;
    maxHeight?: number;
}

observe

This will create an observer that will monitor added and removed nodes. Upon addition/removal our listeners are automatically bound to the textarea's in those nodes. This option defaults to ['body']. Use an empty array to disable the observer behaviour.

loadExistingTextAreas

Additionally to observing the initial elements provided in observe it will also load all textarea's found in them.

textAreaSelector

CSS selector for selecting textareas, defaults to textarea but can easily be changed to eg; textarea.asta to only work on specific text areas.

Size controls

These four options can also be printed as data attribute (which take priority over the config options). Max size takes priority over min size (if max is lower than minimum size max is still used). If both min/max lines and height are specified the largest resulting number wins.

Option Attribute Description
minLines data-asta-min-lines="3" Sizes textareas to a minimum size of N amount of lines. (default: 3)
minHeight data-asta-min-height="150" The minimum height specified in pixel
maxLines data-asta-max-lines="3" Limit the maximum height of the text area to N amount of lines.
maxHeight data-asta-max-height="150" Limits the maxmimum height of the text area to the specified amount of pixels.

Examples

HTML

<textarea data-asta-min-lines="4"></textarea>

Prefilled textarea's are just autosized to the min/max of the content.

<textarea data-asta-max-lines="3">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ratio enim nostra consentit, pugnat oratio. Nobis aliter videtur, recte secusne, postea; Duo Reges: constructio interrete. Si longus, levis; Nam de isto magna dissensio est. Eorum enim est haec querela, qui sibi cari sunt seseque diligunt. Si quicquam extra virtutem habeatur in bonis. Quam si explicavisset, non tam haesitaret. At hoc in eo M.</textarea>

JavaScript

Initializing

import AutoSizeTextArea from './AutoSizeTextArea';

new AutoSizeTextArea({
  observe: ['section.content'],
  minLines: undefined,
  minHeight: 50,
});

Initializer without observing

import AutoSizeTextArea from './AutoSizeTextArea';

const autoSizeTextArea = new AutoSizeTextArea({
  observe: [],
});

...

JavaScript creating a textarea and then loading that to bind all eventlisteners and applying the min height. Note that load and unload also take HTML blocks and then look for textarea nodes inside of it.

const textArea = document.createElement('textarea');
textArea.classList.add('auto-size'); // Not required, just to keep it in line with the initalizer.
document.body.append(textArea);

autoSizeTextArea.load(textArea);

You can remove the listeners using unload when elements are removed.

autoSizeTextArea.unload(textArea);
export interface AutoSizeTextAreaOptions {
observe?: string[];
loadExistingTextAreas?: boolean;
textAreaSelector?: string;
minLines?: number;
minHeight?: number;
maxLines?: number;
maxHeight?: number;
}
export type ObserverListItem = {
element: HTMLElement;
observer: MutationObserver;
}
export default class AutoSizeTextArea {
public static DefaultOptions = {
observe: ['body'],
loadExistingTextAreas: true,
textAreaSelector: 'textarea',
minLines: 3,
minHeight: undefined,
maxLines: undefined,
maxHeight: undefined,
};
private observerList: ObserverListItem[] = [];
private textAreas: HTMLTextAreaElement[] = [];
private options: AutoSizeTextAreaOptions = AutoSizeTextArea.DefaultOptions;
constructor(options: AutoSizeTextAreaOptions = {}) {
Object.assign(this.options, options);
if (this.options.observe.length) {
for (let i = 0; i < this.options.observe.length; i++) {
const elements: NodeListOf<HTMLElement> = document.querySelectorAll(this.options.observe[i]);
for (let j = 0; j < elements.length; j++) {
this.observe(elements[j], this.options.loadExistingTextAreas);
}
}
}
window.addEventListener('resize', this.onResize.bind(this));
}
observe(element: HTMLElement, loadExistingTextAreas: boolean = true) {
if (this.observerList.some(o => o.element === element)) {
return;
}
const observer = new MutationObserver(this.onMutation.bind(this));
observer.observe(element, { subtree: true, childList: true });
this.observerList.push({
observer,
element,
});
if (loadExistingTextAreas) { this.load(element); }
}
disconnect(element: HTMLElement|MutationObserver) {
const observerIndex: number = this.observerList.findIndex(
o => (element instanceof HTMLElement ? o.element : o.observer) === element
);
if (observerIndex === -1) {
return;
}
this.observerList[observerIndex].observer.disconnect();
this.observerList.splice(observerIndex, 1);
}
load(element: HTMLElement) {
if (element instanceof HTMLTextAreaElement) {
return this.loadTextarea(element);
}
const textAreas = <NodeListOf<HTMLTextAreaElement>>element.querySelectorAll(this.options.textAreaSelector);
for (let i = 0; i < textAreas.length; i++) {
this.loadTextarea(textAreas[i]);
}
}
unload(element: HTMLElement) {
if (element instanceof HTMLTextAreaElement) {
return this.unloadTextarea(element);
}
const textAreas = <NodeListOf<HTMLTextAreaElement>>element.querySelectorAll(this.options.textAreaSelector);
for (let i = 0; i < textAreas.length; i++) {
this.unloadTextarea(textAreas[i]);
}
}
loadTextarea(textArea: HTMLTextAreaElement) {
if (this.textAreas.indexOf(textArea) !== -1) {
return;
}
this.textAreas.push(textArea);
textArea.addEventListener('input', this.onChange.bind(this));
this.onChange({ target: textArea })
}
unloadTextarea(textArea: HTMLTextAreaElement) {
const index = this.textAreas.indexOf(textArea);
if (index === -1) {
return;
}
textArea.removeEventListener('input', this.onChange.bind(this));
this.textAreas.splice(index, 1);
}
private onChange(event) {
const el: HTMLTextAreaElement = event.target;
el.style.overflow = 'hidden';
el.style.height = '0';
let height = el.scrollHeight;
const min: number = this.height(el);
if (undefined !== min) {
height = Math.max(height, min);
}
const max: number = this.height(el, true);
if (undefined !== max) {
height = Math.min(height, max);
}
el.style.height = height + 'px';
}
private onResize(event) {
for (let i = 0; i < this.textAreas.length; i++) {
this.onChange({ target: this.textAreas[i] });
}
}
private onMutation(mutations: MutationRecord[], observer: MutationObserver) {
for (let i = 0; i < mutations.length; i++) {
const mutation = mutations[i];
for (let j = 0; j < mutation.addedNodes.length; j++) {
const node = mutation.addedNodes[j];
if (!(node instanceof HTMLElement)) {
continue;
}
this.load(node);
}
for (let j = 0; j < mutation.removedNodes.length; j++) {
const node = mutation.removedNodes[j];
if (!(node instanceof HTMLElement)) {
continue;
}
this.unload(node);
}
}
}
private height(element: HTMLTextAreaElement, readMaxInstead: boolean = false) {
const height: number = readMaxInstead
? parseFloat(element.dataset.astaMaxHeight) || this.options.maxHeight || undefined
: parseFloat(element.dataset.astaMinHeight) || this.options.minHeight || undefined;
const lines: number = readMaxInstead
? parseFloat(element.dataset.astaMaxLines) || this.options.maxLines || undefined
: parseFloat(element.dataset.astaMinLines) || this.options.minLines || undefined;
if (undefined === lines) {
return height;
}
// Calculate element height with N lines.
const computedStyles: CSSStyleDeclaration = getComputedStyle(element);
let elementHeight: number = parseFloat(computedStyles.lineHeight) * lines;
if (computedStyles.boxSizing === 'border-box') {
elementHeight += parseFloat(computedStyles.paddingTop)
+ parseFloat(computedStyles.paddingBottom)
+ parseFloat(computedStyles.borderTopWidth)
+ parseFloat(computedStyles.borderBottomWidth);
}
// Return highest height (either specified in units or result of line calculations).
return undefined === height
? elementHeight
: Math.max(height, elementHeight);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment