October 25, 2024

Ultimate HTML input elements

Text input is such an often used “feature” in html, that you would expect any type of text input scenario to have a simple solution.
But, does it?

What are the problems?

fresh

Well, we certainly have multiple html elements that can help build solutions

  1. The regular input type='text' element, which is limited to single line only.
  2. The regular textArea element for multiple lines, with a weird rows attribute for number of lines control.
  3. The contentEditable attribute, that turns any div into an input area. Mainly for richtext and a headache. Because of browser and OS differences and a potentially massive security hole it opens, it is rarely used directly. Instead use a library that wraps contentEditable and provides UI controls and data-filtering (which you have to do again on the server, if you care about security at all).

Basic problems with basic solutions:

  1. Plain text input: input or textarea
  2. Rich text input: contentEditable-based library or mark-down with basic plain text input.
  3. Number input: input with type number.

Advanced problems:

  1. A multi-line input that starts as 2 lines and grows with more text.
  2. A single line input with overflow text wrap.
  3. Nicely formatted numbers.

Solutions??? Unfortunately, none of the elements have a single property we could set and be done with the problem.

We shall get our hands dirty with some React code. The underlying ideas should be readily convertible to other frameworks or vanilla JS.

Textarea with height fit to content

Let’s look at an auto-growing input first. The main idea is to stack two elements on top of each other. The bottom element is invisible but contains the edited text thus providing the correct height for the parent element. The top element is a textarea that is set to expand to the parent element’s size.
Easy, right:)

The core of the trick is updating the dataset.value in the onChange handler. We use content: attr(data-value) in CSS to set the invisible elements content, which will then in turn resize the parent, which will resize the text-area. Woho!
We could also just use a real element in react and use the value content directly:) but this is such a nice trick.

You can also look at the vanilla JS version from the author of this solution

import { useCallback, useEffect, useRef } from "react";

export const InputWithWrap = (props: {
	value: string,
	single?: boolean, 
	rows?: number,
	disabled?: boolean,
	onChange: (text: string) => void,
	onKeyDown?: React.KeyboardEventHandler<HTMLTextAreaElement>,
	autoFocus?: boolean
}) => {

	const divRef = useRef<HTMLDivElement>(null);

	const onChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
		const text = e.target.value;
		props.onChange(text);
		if (divRef.current)
			divRef.current.dataset.value = text;
	}, [props.onChange]);

	useEffect(() => {
		if (divRef.current) {
			divRef.current.dataset.value = props.value;
			
			if (props.autoFocus && !divRef.current.dataset.focused) {
				divRef.current.dataset.focused = "1";
				(divRef.current.firstElementChild as HTMLElement)?.focus();
			}
		}
	}, [divRef, props.value]);

  // fixme: un-hardcode min-height calculation.

	return <div ref={divRef} className="wrapDiv">
    <textarea className="wrapArea"
      disabled={props.disabled}
      rows={1}
      style={{ minHeight: ((props.rows || 1) * 18 + 4) + "px" }}
      onChange={onChange}
      onKeyDown={props.onKeyDown}
      value={props.value}>
		</textarea>
	</div>
}

Magic happends in the .wrapDiv::after CSS selector. We place the element in the same position as the textArea. Then we make sure it has the correct (same) wrap logic. And, most importantly, we set the content attribute.

.wrapDiv {
  align-items: stretch;
  display: grid;
  grid-template-rows: 0 1fr;
}
.wrapArea {
  grid-area: 2 / 1; /* Ensure textArea is in the same spot as the :after element */
  resize: none;
  font: inherit; /* Ensure same font is used */
  overflow-wrap: anywhere;
}
.wrapDiv::after {
  content: attr(data-value) ' '; /* uses the dataset.value */
  padding: 2px;
  visibility: hidden;
  white-space: pre-wrap; /* Must match textArea whitespace handling */
  overflow-wrap: anywhere;
  grid-area: 2 / 1; /* Ensure textArea is in the same spot as the :after element */
}

How about providing the minimum height? Css or text-area rows. Both options work, the css one allows the designers to adjust the ui without touching the html (react) code.

Single line text input with text wrap

Can we reuse the auto-growing-text-area for wrapped input? You bet we can. The only thing we need to do is remove any newlines the user might have entered (or pasted, etc.). Even easier, right;)

	const onChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
		let text = e.target.value;
		if (props.single) // single line.
			text = text.replace(/\r?\n|\r/g, "");
		props.onChange(text);
		if (divRef.current)
			divRef.current.dataset.value = text;
	}, [props.onChange, props.single]);

Placeholder for text area

But… I need to show a placeholder and the textarea element doesn’t have a placeholder attribute. Well our parent is a grid, so we will just add another span and adjust its visibility depending on the text content (or the lack of).

export const InputWithWrap = (props: {
  //...
	placeholder?: string,
}) => {
  //... same as above

  return  <div ref={divRef} className="wrapDiv">
  	{props.placeholder && !props.value && <span className="wrapPlaceholder">{props.placeholder}</span>}
    <textarea className="wrapArea">
		</textarea>
  </div>
}

And the css for our placeholder. Again we use grid-area to keep it in the same spot as the textArea.

.wrapPlaceholder {
  grid-area: 2 / 1; /* Ensure placeholder is in the same spot as the textArea */
  color: gray;
  place-self: flex-start flex-start;
  padding: 2px; /* match textArea padding */
}

Formatted numeric input

Numbers are easy right. Well yes, until you want to pretty format numbers with thousands separators, uniform number of decimal places and maybe a currency symbol.

We need to employ another trick here. The technical name is just in time input type switcheroo. On focus we set the input type to number and set the value to the “real” number. If the input doesn’t have focus, the input type is general text and contains the formatted numeric value.

Check out the onFocus and onBlur handlers, they contain the meat of the functionality.

The control below provides multiple ways to format the number

  1. decimalPlaces - the number of decimal places user can enter.
  2. formatString - if you need fancy prefix or suffix like percent or currency symbol: USD 0 or 0 %
  3. customFormatter - for caller provided formatting.
import { useState, useRef } from "react";
import { formatNumericValue, formatNumericWith } from "../formatters";

export const NumberInput = (props: {
	value: any, 
	onChange: (value: any) => void, 
	allowEmpty?: boolean, 
	decimalPlaces?: string, 
	customFormatter?: (str: string) => string, 
	formatString?: string,
	disabled?: boolean,
}) => {
	const decimalPlaces = +(props.decimalPlaces || "0");
	const step = decimalPlaces > 0 ? Math.pow(10, -decimalPlaces) : "1";

	const [hasFocus, setFocus] = useState(false);

	let str = props.value;
	if (str === undefined || str === null)
		str = "";
	
  if(str !== "") {
	  if (!hasFocus) {
      if (props.customFormatter)
        str = props.customFormatter(str);
      else if (props.formatString)
        str = formatNumericWith(str, props.formatString);
      else 
        str = formatNumericValue(decimalPlaces, str);
    } else {
      str = parseFloat(str).toFixed(decimalPlaces);
    }
  }
	const inputType = hasFocus ? "number" : "text";

  // remember what we used as editable value, so we don't trigger a onChange on focus & blur without editing!
  const editValue = useRef("");
  if (hasFocus)
    editValue.current = str;

	return (<input
		type={inputType}
		disabled={props.disabled}
		onFocus={e => {
			setFocus(true);
		}}
		onBlur={(e => {
			setFocus(false);
			let str = e.target.value;
			const isEmpty = str === undefined || str === "" || str === null;
			if (props.allowEmpty && isEmpty) {
				props.onChange("");
			}
			else if (str !== editValue.current && !isEmpty) {
				str = parseFloat(str).toFixed(decimalPlaces);
				props.onChange(str);
			}
		})}
		step={step} 
    value={str} 
    onChange={e => props.onChange(e.target.value)}
    />);
}

And the formatting helper functions.

export const formatNumericValue = (precision: any, value: any, locale?: string) => {
	let str = value === undefined || value === null ? "" : value as string;
	const decimalPlaces = +(precision || "0");
	if (str !== "") {
		locale = locale || Intl.NumberFormat().resolvedOptions().locale;
		const fmt = new Intl.NumberFormat(locale, {
			style: "decimal",
			minimumFractionDigits: decimalPlaces,
			maximumFractionDigits: decimalPlaces
		});
		str = fmt.format(parseFloat(str));
	}
	return str;
}

export const formatNumericWith = (value: any, fmtString: string, locale?: string) => {
	const re = /(\{\d+\})/.exec(fmtString);
	if (re) {
		const decimalPlaces = +re[0];
		const numericValue = formatNumericValue(decimalPlaces, value, locale);
		return fmtString.substring(0, re.index) + numericValue + fmtString.substring(re.index + re[0].length);
	}
	return "";
}

Done?

These are just a few of the tricks we've collected. Feel free to reuse and adapt to your needs.

In the future we shall continue with tips and tricks for select and radio elements. Stay tuned!

Happy hacking

Continue reading

Drag and drop (it like it's hot)

November 03, 2024
Drag and drop (it like it's hot)

Skeuomorphism - the idea that digital elements resemble their real-world counterparts - arguably reached its peak with Apple’s iOS 6. However the idea is both much older than iOS and still very much alive today.

Processing bank transfer in Salesforce.com

October 11, 2024
Processing bank transfer in Salesforce.com

Does your organization receive payments (donations) via bank transfer (wire-transfer)? Do you want to see the transactions inside Salesforce?

Sign in with Facebook

October 04, 2024
Sign in with Facebook

Do you have a new app?
Do you want an easy way for users to sign-up and login?
Social sign-up & login is the answer. Let's make it happen with Facebook.

©2022-2024 Inuko s.r.o
Privacy PolicyCloud
ENSKCZ
ICO 54507006
VAT SK2121695400
Vajanskeho 5
Senec 90301
Slovakia EU

contact@inuko.net