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?
Well, we certainly have multiple html elements that can help build solutions
input type='text'
element, which is limited to single line only.textArea
element for multiple lines, with a weird rows attribute for number of lines control.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:
input
or textarea
contentEditable
-based library or mark-down
with basic plain text input.input
with type number.Advanced problems:
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.
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.
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]);
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 */
}
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
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 "";
}
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