Foundations

Values as text

What a value looks like as text and how text is read back, so cells copy and paste between applications.

Markdown

Values 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.

Select all
Name
Price
In stock
Status
Released
Featured
1
$8.00
0
Draft
2
Rust Kettle 1
$35.61
161
Draft
Feb 6, 2026
3
Ink Mug 1
$10.26
226
In review
Aug 7, 2026
4
Oatmeal Saucer 1
$37.87
387
In review
Sep 12, 2026
5
Rust Tray 1
$12.52
52
Live
Mar 13, 2026
6
Ink Jug 1
$77.17
117
Draft
Aug 14, 2026
7
Oatmeal Bowl 1
$14.78
278
Draft
Oct 19, 2026
8
Rust Plate 1
$79.43
343
In review
Mar 20, 2026

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.

See also Schemas, Numbers and Dates.

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.json
Source

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" }) });
  }
}