Components

Text field

Text that people type, of one line or several, kept as typed and checked when committed.

Markdown

A text field takes text that people type: a name, a title, a note. It holds one line, or several where the text is naturally longer.

A note

A title of at most twenty-four characters, an address in a set form, and a body of several lines. Type past a limit: nothing stops you, and the field says so when you leave it.

Lowercase letters, digits and hyphens.

SavedField notes · /field-notes Typing is left alone. Limits are checked when you are done.
Code
"use client";

import { TextField } from "@publictheta/ui/components/text-field/text-field.tsx";
import { useId, useState } from "react";

export default function Note() {
  const ids = { title: useId(), slug: useId(), body: useId() };
  const [title, setTitle] = useState<string | null>("Field notes");
  const [slug, setSlug] = useState<string | null>("field-notes");
  const [body, setBody] = useState<string | null>(
    "Typing is left alone.\nLimits are checked when you are done.",
  );

  return (
    <div className="grid w-full max-w-md grid-cols-[auto_1fr] items-start gap-x-4 gap-y-2 text-sm">
      <label htmlFor={ids.title} className="pt-1.5 text-fg-muted">
        Title
      </label>
      <TextField id={ids.title} maxLength={24} required value={title} onValueChange={setTitle} />
      <label htmlFor={ids.slug} className="pt-1.5 text-fg-muted">
        Address
      </label>
      <div>
        <TextField
          id={ids.slug}
          pattern="[a-z0-9-]+"
          aria-describedby={`${ids.slug}-hint`}
          value={slug}
          onValueChange={setSlug}
        />
        <p id={`${ids.slug}-hint`} className="mt-1 text-xs text-fg-muted">
          Lowercase letters, digits and hyphens.
        </p>
      </div>
      <label htmlFor={ids.body} className="pt-1.5 text-fg-muted">
        Body
      </label>
      <TextField id={ids.body} multiline rows={4} value={body} onValueChange={setBody} />
      <span className="text-fg-muted">Saved</span>
      <output className="whitespace-pre-wrap text-fg-strong">
        {title ?? "—"} · /{slug ?? ""}
        {"\n"}
        {body ?? ""}
      </output>
    </div>
  );
}

When to use

Use a text field for text with no fixed set of answers. When the answer is one of a known few, offer a select; when it is a number or a date, use the field made for it, which understands what is typed.

Best practices

Keep text as it was typed. Do not trim, change case or otherwise rewrite what people enter, unless the field's purpose is a form they expect. Where space at the ends is never meant — an address, an identifier — drop it when the text is committed, and treat space alone as no text.

Never cut typing short. A limit on length is checked when the text is committed and reported then. A field that refuses characters past its limit breaks an input method's composition, and loses what was pasted without saying so.

Count characters as people see them. A limit of twenty characters allows twenty of any script, and an emoji is one.

Say what form is expected before it is wrong. Where text must follow a pattern, say so in the label or beside the field, not only in the error.

Choose one line or several by the text. A single line for a name or a title; several for a description. In a field of several lines, Return makes a new line, and Command-Return (Control-Enter) commits.

Label the field with what it holds. A placeholder is not a label: it disappears when typing starts.

Platform considerations

Keyboard. Editing follows Editing values.

Touch. The field is at least 44 CSS pixels tall, with text large enough that the page is not zoomed on focus. The keyboard's Return key says what it does: a new line in several lines, done in one. A field of several lines grows with its text.

Assistive technology. The field is a text field with its name; text that breaks a limit marks it invalid.

Behaviour

Semantics

Is a text field with its name, of one line or several
const screen = await render(
  <>
    <TextField aria-label="Title" defaultValue="Notes" />
    <TextField aria-label="Body" multiline defaultValue={"One\nTwo"} />
  </>,
);
await expect.element(field(screen)).toHaveValue("Notes");
await expect.element(field(screen, "Body")).toHaveValue("One\nTwo");
expect(field(screen, "Body").element().tagName).toBe("TEXTAREA");
Has no accessibility violations, valid or invalid
const screen = await render(<TextField aria-label="Title" maxLength={3} />);
await expectAccessible(screen.container);
await userEvent.fill(field(screen), "too long");
await userEvent.keyboard("{Enter}");
await expect.element(field(screen)).toHaveAttribute("aria-invalid", "true");
await expectAccessible(screen.container);

Typing and committing

Text is kept exactly as typed, spaces included
const onValueChange = vi.fn();
const screen = await render(<TextField aria-label="Title" onValueChange={onValueChange} />);
await userEvent.fill(field(screen), "  two  words ");
await userEvent.keyboard("{Enter}");
expect(onValueChange).toHaveBeenLastCalledWith("  two  words ");
Where space at the ends is never meant, it is dropped at commit, and space alone is no text
const onValueChange = vi.fn();
const screen = await render(
  <>
    <TextField aria-label="Title" trim onValueChange={onValueChange} />
    <input aria-label="After" />
  </>,
);
await userEvent.fill(field(screen), "  a@example.com ");
await userEvent.keyboard("{Enter}");
expect(onValueChange).toHaveBeenLastCalledWith("a@example.com");
await expect.element(field(screen)).toHaveValue("a@example.com");
await userEvent.fill(field(screen), "   ");
await userEvent.keyboard("{Enter}");
expect(onValueChange).toHaveBeenLastCalledWith(null);
A pattern that is not a regular expression constrains nothing, rather than failing
const screen = await render(<TextField aria-label="Title" pattern="(" />);
await userEvent.fill(field(screen), "anything");
await userEvent.keyboard("{Enter}");
await expect.element(field(screen)).not.toHaveAttribute("aria-invalid");
A limit on length never stops typing; it is reported at commit
const onValueChange = vi.fn();
const screen = await render(
  <TextField aria-label="Title" maxLength={5} onValueChange={onValueChange} />,
);
expect(field(screen).element().hasAttribute("maxlength")).toBe(false);
await userEvent.fill(field(screen), "longer than five");
await expect.element(field(screen)).toHaveValue("longer than five");
await userEvent.keyboard("{Enter}");
await expect.element(field(screen)).toHaveAttribute("aria-invalid", "true");
expect(onValueChange).not.toHaveBeenCalled();
Length is counted in characters as people see them
const onValueChange = vi.fn();
const screen = await render(
  <TextField aria-label="Title" maxLength={3} onValueChange={onValueChange} />,
);
await userEvent.fill(field(screen), "👨‍👩‍👧‍👦é日");
await userEvent.keyboard("{Enter}");
expect(onValueChange).toHaveBeenLastCalledWith("👨‍👩‍👧‍👦é日");
Text that does not match the field's pattern is invalid
const screen = await render(<TextField aria-label="Title" pattern="[a-z-]+" defaultValue="ok" />);
await userEvent.fill(field(screen), "Not OK");
await userEvent.keyboard("{Enter}");
await expect.element(field(screen)).toHaveAttribute("aria-invalid", "true");
await userEvent.fill(field(screen), "now-ok");
await userEvent.keyboard("{Enter}");
await expect.element(field(screen)).not.toHaveAttribute("aria-invalid");
A composition longer than the limit is not cut shortChromium, Chromium · touch

Not yet written.

Several lines

The key that ends a line is offered as a new line in several lines, and as done in one
const screen = await render(
  <>
    <TextField aria-label="Title" />
    <TextField aria-label="Body" multiline />
  </>,
);
await expect.element(field(screen)).toHaveAttribute("enterkeyhint", "done");
await expect.element(field(screen, "Body")).toHaveAttribute("enterkeyhint", "enter");
Enter makes a new line and does not commit
const onValueChange = vi.fn();
const screen = await render(<TextField aria-label="Body" multiline onValueChange={onValueChange} />);
await userEvent.click(field(screen, "Body"));
await userEvent.keyboard("One{Enter}Two");
await expect.element(field(screen, "Body")).toHaveValue("One\nTwo");
expect(onValueChange).not.toHaveBeenCalled();
The command key with Enter commits, and so does leaving the field
const onValueChange = vi.fn();
const screen = await render(
  <>
    <TextField aria-label="Body" multiline onValueChange={onValueChange} />
    <input aria-label="After" />
  </>,
);
await userEvent.click(field(screen, "Body"));
await userEvent.keyboard(`One{${COMMAND}>}{Enter}{/${COMMAND}}`);
expect(onValueChange).toHaveBeenLastCalledWith("One");
await userEvent.keyboard("{Enter}Two");
await userEvent.click(after(screen));
expect(onValueChange).toHaveBeenLastCalledWith("One\nTwo");
Escape abandons the edit
const screen = await render(body());
await userEvent.click(field(screen, "Body"));
await userEvent.keyboard("{End} more{Escape}");
await expect.element(field(screen, "Body")).toHaveValue("One");

Touch

On a touchscreen, the field is at least 44 pixels tallChromium · touch, WebKit · touch

Not yet written.

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/text-field.json
Source

src/components/text-field/text-field.tsx

"use client";

import { useMemo } from "react";
import type { ComponentProps, ReactElement } from "react";
import { fieldClassName } from "../../lib/field-class.ts";
import { mergeProps } from "../../lib/merge-props.ts";
import { cn } from "../../lib/utils.ts";
import { textType } from "../../primitives/text-type.ts";
import type { TextTypeOptions } from "../../primitives/text-type.ts";
import { useField } from "../../primitives/use-field.ts";

type Shared = TextTypeOptions & {
  value?: string | null;
  defaultValue?: string | null;
  onValueChange?: (value: string | null) => void;
  /** Hears why what was committed is not a value, and `null` once it is. */
  onInvalidChange?: (reason: string | null) => void;
};

type Omitted = "value" | "defaultValue" | "onChange" | "minLength" | "maxLength" | "pattern";

export type TextFieldProps =
  | (Omit<ComponentProps<"input">, Omitted> & Shared & { multiline?: false })
  | (Omit<ComponentProps<"textarea">, Omitted> & Shared & { multiline: true });

/**
 * A field for text, of one line or several. Its limits are checked at commit
 * rather than enforced while typing: the native `maxlength` would cut an input
 * method's composition short, so it is never set.
 */
export function TextField({
  value,
  defaultValue,
  onValueChange,
  minLength,
  maxLength,
  pattern,
  trim,
  multiline,
  required,
  onInvalidChange,
  className,
  ...props
}: TextFieldProps): ReactElement {
  const type = useMemo(
    () => textType({ minLength, maxLength, pattern, trim }),
    [minLength, maxLength, pattern, trim],
  );
  const field = useField({ type, value, defaultValue, onValueChange, required, multiline, onInvalidChange });
  const shared = {
    "aria-required": required || undefined,
    "data-invalid": field.invalid === null ? undefined : "",
  };

  if (multiline) {
    return (
      <textarea
        rows={3}
        enterKeyHint="enter"
        {...shared}
        {...mergeProps(field.inputProps, props as ComponentProps<"textarea">)}
        className={cn(
          fieldClassName,
          // As tall as its text, between three lines and what a view can spare.
          "h-auto max-h-[50dvh] py-1.5 leading-relaxed [field-sizing:content] pointer-coarse:h-auto",
          className,
        )}
      />
    );
  }
  return (
    <input
      type="text"
      enterKeyHint="done"
      {...shared}
      {...mergeProps(field.inputProps, props as ComponentProps<"input">)}
      className={cn(fieldClassName, className)}
    />
  );
}