Foundations
Text
Keeping text as it is typed, counting it as people count, and bringing every way of typing a character into one form before a value is read.
MarkdownText is the value people type most, and the one an interface is most tempted to correct. An interface that handles it well keeps what was typed, counts it as people count, and checks it only when the person has finished — and, beneath every field that reads a number or a date, brings the many ways a character can be typed into one form before reading it.
Best practices
Keep text as it was typed. Do not trim it, change its case, or replace its characters. What someone typed is what they meant; the space at the end of a line of poetry is theirs.
Drop space at the ends only where it is never meant. An email address, an identifier or a code is not changed by the space around it, and space is often pasted with it. There, space at either end is dropped when the text is committed, and text that is only space is no text at all.
Count characters as people see them. A limit of twenty characters is twenty of what a reader would count, in any script: an emoji made of several code points is one, a flag is one, a letter with its accent is one, whether the accent was typed as part of it or after it.
Check when the text is finished, never while it is typed. A limit that stops the keyboard cuts a word in half, breaks an input method's composition — which passes through longer text on its way to shorter — and silently drops the end of what was pasted. Text past a limit is kept, and marked, and the reason said, when it is committed.
Match a pattern against the whole text. A pattern describes what the text is, not something it contains: a code of letters does not accept letters followed by a digit. Patterns are read as Unicode, so "a letter" is a letter of any script. A pattern that is not a valid expression constrains nothing; it is a fault of the field, and is not the person's to suffer.
Show text as it is held. What was typed is shown back unchanged.
No text is no value. An empty field holds nothing, which a field that requires a value says; it does not hold an empty string the person never chose.
What is typed, made plain
People type the same thing in many ways, and a field that reads a number or a date should accept them all without each field handling each. Before a value is read from text:
- characters an input method types in full width (
123,/,-) are the ordinary characters; - digits of every numbering system a locale may use — Arabic-Indic, Devanagari, Bengali, Thai and others — are the digits 0 to 9;
- every minus sign, and every dash that looks like one, is a minus;
- the invisible marks some locales set around signs and separators, to keep them in order in right-to-left text, are let go.
A number is read from a stricter form, which never lets something that only looks like a digit become one:
- a superscript, a fraction or an enclosed numeral is not a digit:
5²is not 52, and½is not 12; - a minus is a minus, but a dash between two numbers is a dash, so
10–20stays a range, which is no number; - primes, and the curly quotes a keyboard substitutes for them, are feet and
inches:
5′10″and5’10”are5'10"; - what a Japanese keyboard types for a minus, a decimal point and a comma —
ー,。,、— is read as such where it touches a digit, and is left as it is elsewhere; - every kind of space is a space.
Text that is already plain ASCII, which is most of what is typed, is left exactly as it is.
Platform considerations
Input methods. A field must leave text alone while an input method is composing: nothing is checked, counted or rewritten until the composition is committed.
Assistive technology. A field whose text breaks a limit or a pattern is marked invalid, with the reason as its description.
See also Text field, Numbers and Dates.
Behaviour
Reading
Text is kept exactly as typed: nothing is trimmed, folded or rewritten
fc.assert(fc.property(fc.string({ minLength: 1, unit: "grapheme" }), (text) => read(text) === text));
expect(read(" Ünïcode ")).toBe(" Ünïcode ");No text is no value
expect(read("")).toBe("(empty)");Where space at the ends is never meant, it is dropped, and space alone is no text
expect(read(" ada@example.com\n", { trim: true })).toBe("ada@example.com");
expect(read(" \t ", { trim: true })).toBe("(empty)");
expect(read(" \t ")).toBe(" \t ");Is shown as it is held
expect(textType().format(" as typed ")).toBe(" as typed ");Length
Is counted in characters as people see them: a family, a flag and an accented letter are one each
const family = "👨👩👧";
const flag = "🇯🇵";
const accented = "é";
expect(read(`${family}${flag}${accented}`, { maxLength: 3 })).toBe(`${family}${flag}${accented}`);
expect(read(`${family}${flag}${accented}!`, { maxLength: 3 })).toBe("(maxLength)");Text shorter than the least or longer than the most is refused, and says which
expect(read("ab", { minLength: 3 })).toBe("(minLength)");
expect(read("abcd", { maxLength: 3 })).toBe("(maxLength)");
expect(read("abc", { minLength: 3, maxLength: 3 })).toBe("abc");Patterns
A pattern is matched by the whole text, not by a part of it
expect(read("abc", { pattern: "[a-z]+" })).toBe("abc");
expect(read("abc1", { pattern: "[a-z]+" })).toBe("(pattern)");
expect(read("a|b", { pattern: "a|b" })).toBe("(pattern)");A pattern is read as Unicode: a letter is a letter of any script
expect(read("Zürich", { pattern: "\\p{L}+" })).toBe("Zürich");
expect(read("東京", { pattern: "\\p{L}+" })).toBe("東京");A pattern that is not a regular expression constrains nothing, rather than failing
expect(read("anything", { pattern: "(" })).toBe("anything");What is typed, made plain
What an input method types in full width is read as the ordinary characters
expect(normalizeTyped("123/45")).toBe("123/45");
expect(normalizeTyped("ABC")).toBe("ABC");Digits of any numbering system are read as the digits 0 to 9
expect(["١٢٣", "۱۲۳", "१२३", "১২৩", "๑๒๓"].map(normalizeTyped)).toEqual([
"123",
"123",
"123",
"123",
"123",
]);Every minus sign, and every dash that looks like one, is a minus
expect(["−5", "–5", "-5", "﹣5", "‒5"].map(normalizeTyped)).toEqual(["-5", "-5", "-5", "-5", "-5"]);The marks some locales set around signs and separators are let go
expect(normalizeTyped("-5")).toBe("-5");Plain ASCII is already plain, and is left exactly as it is
fc.assert(
fc.property(
fc.string({ unit: "binary-ascii" }),
(text) => normalizeTyped(text) === text && normalizeNumeric(text) === text,
),
);What is typed, as a number is read from it
A superscript, a fraction or an enclosed numeral is not a digit: 5² is not 52
for (const text of ["5²", "½", "①", "㋀"]) expect(normalizeNumeric(text)).toContain(NOT_A_DIGIT);A minus sign is a minus; a dash is a dash, which is a minus only before a number
expect(normalizeNumeric("−3")).toBe("-3");
expect(normalizeNumeric("10—20")).toBe(`10${DASH}20`);
expect(normalizeNumeric("10–20")).toBe(`10${DASH}20`);Primes, and the quotes a keyboard puts in their place, are feet and inches
expect(normalizeNumeric("5′10″")).toBe(`5'10"`);
expect(normalizeNumeric("5’10”")).toBe(`5'10"`);What a Japanese keyboard types for a minus, a point and a comma is read as such beside a digit
expect(normalizeNumeric("ー5")).toBe("-5");
expect(normalizeNumeric("1。5")).toBe("1.5");
expect(normalizeNumeric("1、000")).toBe("1,000");
// Away from a digit, a long vowel mark is a letter.
expect(normalizeNumeric("ラーメン")).not.toContain("-");The Arabic percent sign is a percent sign, and the spaces of every kind a space
expect(normalizeNumeric("٥٪")).toBe("5%");
expect(normalizeNumeric("1 000 5")).toBe("1 000 5");A digit no numbering system names is not taken for one
// Adlam digits are digits, but of no system read here.
expect(normalizeNumeric("𞥑")).toBe(NOT_A_DIGIT);React
Install
npx shadcn@latest add https://ui.publictheta.com/r/text-type.jsonSource
src/primitives/text-type.ts
/**
* The text value type: text is kept exactly as typed — nothing is trimmed or
* rewritten — and its limits are checked when it is committed, never while it
* is being typed, so a limit cannot cut a word or an input method's composition
* short.
*/
import { characterCount } from "./schema.ts";
import type { ParseResult } from "./use-field.ts";
export interface TextTypeOptions {
/** Lengths in characters as people count them. */
readonly minLength?: number | undefined;
readonly maxLength?: number | undefined;
/** A regular expression the whole text must match. */
readonly pattern?: string | undefined;
/**
* Whether space at either end is dropped when the text is committed — for an
* address or an identifier, where it is never meant. Text that is only space
* is then no text.
*/
readonly trim?: boolean | undefined;
}
/** A pattern that cannot be compiled constrains nothing, rather than breaking what uses it. */
function compile(pattern: string | undefined): RegExp | undefined {
if (pattern === undefined) return undefined;
try {
return new RegExp(`^(?:${pattern})$`, "u");
} catch {
return undefined;
}
}
export interface TextType {
readonly parse: (text: string) => ParseResult<string>;
readonly format: (value: string) => string;
}
export function textType({ minLength, maxLength, pattern, trim = false }: TextTypeOptions = {}): TextType {
const expression = compile(pattern);
return {
parse(typed) {
const text = trim ? typed.trim() : typed;
if (text === "") return { ok: false, reason: "empty" };
if (minLength !== undefined || maxLength !== undefined) {
const length = characterCount(text);
if (minLength !== undefined && length < minLength) return { ok: false, reason: "minLength" };
if (maxLength !== undefined && length > maxLength) return { ok: false, reason: "maxLength" };
}
if (expression !== undefined && !expression.test(text)) return { ok: false, reason: "pattern" };
return { ok: true, value: text };
},
format: (value) => value,
};
}