Text field

One line of text, with optional addons beside it.

€

In euros, with a comma before the cents.

Installation

npx shadcn@latest add @formdef/text-field

Register it under widgets in lib/formdef/index.tsx, as widgets: { text: TextField }. The widget serves string values, so the schema's input at its path is a string.

Usage

export const  = createForm({
  : .({ : .().(1, 'Enter your name'), : .() }),
  : { : '', : '' },
  fields: {
    name: { : 'text', : { : 'Name', : 'name' } },
    email: { : 'text', : { : 'Email', : 'email', : 'email' } },
  },
})

type takes the text-like input types: text, email, search, tel and url. A password has a widget of its own, and so does a whole number.

Addons

addons places content inside the input's border, such as a currency or a unit. Each addon needs an id, which React uses as its key, and takes an align:

export const  = createForm({
  : .({ : .() }),
  : { : '' },
  fields: {
    domain: {
      : 'text',
      : {
        : 'Domain',
        : [
          { : 'scheme', : 'https://', : 'inline-start' },
          { : 'suffix', : '.com', : 'inline-end' },
        ],
      },
    },
  },
})

Amounts

An amount or a decimal is a text field with inputMode: 'decimal'. A phone shows a keypad with a decimal separator, and the comma a German user types stays a comma. An <input type="number"> would turn it into a dot or drop it.

The value stays a string. TanStack Form hands onSubmit the values as they were typed, not the schema's output, so convert the amount where you read it:

const  = /^\d+(,\d{1,2})?$/
const  = 100

const  = (: string): number =>
  .((.(',', '.')) * )

export const  = createForm({
  : .({ : .().(, 'Enter an amount such as 12,50') }),
  : { : '' },
  fields: {
    amount: { : 'text', : { : 'Amount', : 'decimal' } },
  },
  onSubmit: ({  }) => ((.)),
})

Props

Prop

Type

Source

import { type FieldOf, fieldParts } from '@formdef/core'
import type { ComponentProps } from 'react'

import { FieldFrame } from '@/components/formdef/field-frame'
import { Input } from '@/components/ui/input'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'
import type { Addon } from '@/lib/formdef/addons'
import type { FieldLabels } from '@/lib/formdef/field-labels'

type Attrs = Pick<
  ComponentProps<'input'>,
  | 'className'
  | 'placeholder'
  | 'autoComplete'
  | 'autoFocus'
  | 'disabled'
  | 'readOnly'
  | 'required'
  | 'maxLength'
  | 'inputMode'
  | 'spellCheck'
> & {
  type?: 'text' | 'email' | 'search' | 'tel' | 'url'
}

type TextFieldProps = FieldOf<string> &
  FieldLabels &
  Attrs & {
    addons?: Addon[]
  }

export const TextField = ({
  field,
  label,
  labelAside,
  description,
  addons = [],
  ...attrs
}: TextFieldProps) => {
  const parts = fieldParts(field, { description })
  const value = field.state.value ?? ''
  const hasAddons = addons.length > 0
  const Control = hasAddons ? InputGroupInput : Input
  const input = (
    <Control
      type="text"
      {...attrs}
      {...parts.control}
      value={value}
      onChange={(event) => field.handleChange(event.target.value)}
    />
  )

  return (
    <FieldFrame
      parts={parts}
      label={label}
      labelAside={labelAside}
      description={description}
      disabled={attrs.disabled}
    >
      {hasAddons ? (
        <InputGroup>
          {input}
          {addons.map((addon) => (
            <InputGroupAddon key={addon.id} align={addon.align}>
              {addon.content}
            </InputGroupAddon>
          ))}
        </InputGroup>
      ) : (
        input
      )}
    </FieldFrame>
  )
}