Foundations
Values as text
What a value looks like as text and how text is read back, so cells copy and paste between applications.
MarkdownValues move between applications as text: copied from one grid, pasted into another, typed into a cell. What a value looks like as text, and how text is read back as a value, decide whether that movement simply works.
Copying and pasting
Select a block and copy it; paste it into a spreadsheet and back. Paste yes into Featured, in review into Status, ¥1,200 into In stock: each is read as its column's kind, and what cannot be read is left out.
12 rows · 0 edits
Code
"use client";
import { ProductGrid } from "./products.tsx";
export default function Sheet() {
return <ProductGrid count={12} height={300} />;
}Best practices
Copy what is shown. Put cells on the clipboard as people see them — a date as it is written, a choice by its label — in the tab-separated form every spreadsheet reads and writes.
Read what is pasted as the cell's kind. A pasted number is read as a number, with whatever separators, digits and currency sign it came with; a pasted date in any of the forms dates are written; a choice by its label or its value; a yes-or-no from the words spreadsheets and people use for it.
Leave alone what cannot be read. Text that is not a value of a cell's kind does not overwrite the cell, and does not become an error in it.
Treat empty as empty. A cell with no value copies as empty text, and empty text pasted into a cell clears it.
Read pasted text as written. What is pasted is data: it is read as the value it writes and no further. Arithmetic is worked out when a person types it, not when a column of dates or telephone numbers lands in a number's cell — those are not numbers, and are left out.
Refuse a pasted value beyond the cell's limits. A number typed beyond its range is held within it, where the person typing can see it change. A pasted one is refused, since nobody is watching each cell; a date beyond its range is refused either way.
Never fail on what a record holds. A value that is not of its field's kind — a timestamp where a date belongs, text where a number does — is shown as nothing, and marked; it does not break the view.
Fill a selection from one cell. Pasting a single value into a larger selection fills it; pasting a block places it from the active cell, and stops at the grid's edge.
Behaviour
A value of each kind is shown as its kind is, and reads back as itself
const cases = [
[schema({ kind: "number", format: "currency", currency: "JPY" }), 1200, "¥1,200"],
[schema({ kind: "number", dimension: "length", unit: "mm", precision: 1 }), 25.4, "25.4 mm"],
[schema({ kind: "date" }), "2026-10-13", "Oct 13, 2026"],
[schema({ kind: "text" }), " as typed ", " as typed "],
[schema({ kind: "boolean" }), true, "TRUE"],
[schema({ kind: "boolean" }), false, "FALSE"],
[status, "in-review", "In review"],
] as const;
for (const [definition, value, text] of cases) {
const codec = textCodec(definition);
expect(codec.format(value)).toBe(text);
expect(codec.parse(text)).toEqual({ ok: true, value });
}No value is the empty text, and the empty text is no value
for (const definition of [
schema({ kind: "number" }),
schema({ kind: "date" }),
schema({ kind: "text" }),
schema({ kind: "boolean" }),
status,
]) {
expect(textCodec(definition).format(null)).toBe("");
expect(read(definition, "")).toBe("empty");
}Numbers and dates are read as forgivingly as their fields read them
expect(
read(schema({ kind: "number", format: "currency", currency: "JPY" }), "1,200円", "ja-JP"),
).toBe(1200);
expect(
read(schema({ kind: "number", format: "currency", currency: "JPY" }), "¥1,200", "ja-JP"),
).toBe(1200);
expect(read(schema({ kind: "number" }), "1.234,5")).toBe(1234.5);
expect(read(schema({ kind: "date" }), "2026/10/13")).toBe("2026-10-13");Typed, a number beyond its range is held within it, and a date beyond its range is refused
expect(read(schema({ kind: "number", minimum: 0, maximum: 100 }), "250")).toBe(100);
expect(read(schema({ kind: "date", minimum: "2026-01-01" }), "2025-06-01")).toBe("minimum");Pasted, a value beyond its range is refused, naming the end it is beyond: nobody is there to see it moved
const share = schema({ kind: "number", minimum: 0, maximum: 100 });
expect(pasted(share, "250")).toBe("maximum");
expect(pasted(share, "-1")).toBe("minimum");
expect(pasted(share, "100")).toBe(100);
expect(pasted(schema({ kind: "date", maximum: "2026-12-31" }), "2027-03-05")).toBe("maximum");Pasted, text is read as written and no further: what looks like arithmetic is not worked out
const number = schema({ kind: "number" });
expect(read(number, "12*3")).toBe(36);
for (const text of ["12*3", "2026-10-13", "555-1234", "10/13/2026"])
expect(pasted(number, text)).toBe("invalid");
expect(pasted(number, "(1,234.50)")).toBe(-1234.5);
expect(pasted(number, "1,234.5")).toBe(1234.5);A value that is not of the field's kind is shown as nothing, rather than failing
expect(textCodec(schema({ kind: "date" })).format("2026-10-13T00:00:00Z")).toBe("");
expect(textCodec(schema({ kind: "date" })).format("soon")).toBe("");
expect(textCodec(schema({ kind: "number" })).format(Number.NaN)).toBe("");
expect(textCodec(schema({ kind: "number" })).format("12")).toBe("");A yes-or-no is read from the words spreadsheets and people use
const flag = schema({ kind: "boolean" });
for (const text of ["TRUE", "true", "Yes", "1", "✓"]) expect(read(flag, text)).toBe(true);
for (const text of ["FALSE", "no", "0"]) expect(read(flag, text)).toBe(false);
expect(read(flag, "maybe")).toBe("invalid");A choice is read by its label or by its value, whatever the case
expect(read(status, "in review")).toBe("in-review");
expect(read(status, "IN-REVIEW")).toBe("in-review");
expect(read(status, "Archived")).toBe("invalid");A choice written exactly as a value is that value, whatever else it might be the label of
const swapped = schema({
kind: "choice",
options: [
{ value: "a", label: "b" },
{ value: "b", label: "a" },
],
});
expect(read(swapped, "b")).toBe("b");
expect(read(swapped, "B")).toBe("a");A schema given by reference is shown and read as what it refers to
const price = schema({ kind: "number", precision: 2 });
const codec = textCodec({ kind: "lazy", schema: () => price });
expect(codec.format(12.5)).toBe("12.5");
expect(codec.parse("1,250.50")).toEqual({ ok: true, value: 1250.5 });React
Install
npx shadcn@latest add https://ui.publictheta.com/r/schema-text.jsonSource
src/primitives/schema-text.ts
/**
* A scalar value as text, by the kind its schema gives it: how a grid shows a
* cell, what it puts on the clipboard, and how it reads what is typed into one or
* pasted. The reading is as forgiving as a field's — `1,200円` is a number in
* a column of yen — so cells copied from anywhere land as values of the right
* kind.
*/
import { dateType, isDate } from "./date-type.ts";
import { numberType } from "./number-type.ts";
import { resolve } from "./schema.ts";
import type { Schema } from "./schema.ts";
import { angle, length } from "./units.ts";
import type { ParseResult } from "./use-field.ts";
export interface TextCodec {
/** The text for a value; the empty string for none. */
readonly format: (value: unknown) => string;
/** The value in a text as it is typed, or why there is none. An empty text is no value. */
readonly parse: (text: string) => ParseResult<unknown>;
/**
* The value in a text as data holds it — a cell pasted from elsewhere. Read
* as written and no further: arithmetic is not worked out, and a value
* outside the field's range is refused rather than moved into it, since
* nobody is there to see it change.
*/
readonly read: (text: string) => ParseResult<unknown>;
}
const DIMENSIONS = { length, angle };
const YES = new Set(["true", "yes", "y", "1", "on", "✓", "はい"]);
const NO = new Set(["false", "no", "n", "0", "off", "いいえ"]);
/** A codec whose pasted text is read as its typed text is. */
function same(codec: Omit<TextCodec, "read">): TextCodec {
return { ...codec, read: codec.parse };
}
export function textCodec(given: Schema, locale = "en-US"): TextCodec {
// A schema given by reference is read and shown as what it refers to.
const schema = resolve(given);
switch (schema.kind) {
case "number": {
const type = numberType({
locale,
minimum: schema.minimum,
maximum: schema.maximum,
step: schema.step,
precision: schema.precision,
kind: schema.format,
currency: schema.currency,
units: schema.dimension === undefined ? undefined : DIMENSIONS[schema.dimension],
unit: schema.unit,
});
return {
format: (value) => (typeof value === "number" && Number.isFinite(value) ? type.format(value) : ""),
parse(text) {
const result = type.parse(text);
return result.ok ? { ok: true, value: type.constrain(result.value) } : result;
},
read(text) {
const result = type.parse(text, { arithmetic: false });
if (!result.ok) return result;
if (type.minimum !== undefined && result.value < type.minimum)
return { ok: false, reason: "minimum" };
if (type.maximum !== undefined && result.value > type.maximum)
return { ok: false, reason: "maximum" };
return { ok: true, value: type.constrain(result.value) };
},
};
}
case "date": {
const type = dateType({ locale, minimum: schema.minimum, maximum: schema.maximum });
return same({
// Only a date is shown as one: anything else a record holds here would not format.
format: (value) => (typeof value === "string" && isDate(value) ? type.format(value) : ""),
// A date outside the field's range is refused by the type itself.
parse: type.parse,
});
}
case "boolean":
return same({
format: (value) => (typeof value === "boolean" ? (value ? "TRUE" : "FALSE") : ""),
parse(text) {
const word = text.trim().toLowerCase();
if (word === "") return { ok: false, reason: "empty" };
if (YES.has(word)) return { ok: true, value: true };
if (NO.has(word)) return { ok: true, value: false };
return { ok: false, reason: "invalid" };
},
});
case "choice":
return same({
format: (value) => schema.options.find((option) => option.value === value)?.label ?? "",
parse(text) {
const word = text.trim().toLowerCase();
if (word === "") return { ok: false, reason: "empty" };
// By its value exactly, as data holds it; else by its label or value, as a person writes it.
const option =
schema.options.find((candidate) => candidate.value === text.trim()) ??
schema.options.find(
(candidate) => candidate.label.toLowerCase() === word || candidate.value.toLowerCase() === word,
);
return option === undefined ? { ok: false, reason: "invalid" } : { ok: true, value: option.value };
},
});
case "text":
return same({
format: (value) => (typeof value === "string" ? value : ""),
parse: (text) => (text === "" ? { ok: false, reason: "empty" } : { ok: true, value: text }),
});
default:
return same({ format: () => "", parse: () => ({ ok: false, reason: "invalid" }) });
}
}