Field and form
A label, a frame, a description and an error for any input, and a form that lines them up. When a check fails, the frame turns red, a ! cell opens at its start and the message says what to do.
Loading the live example…
"use client"
import { useState } from "react"
import { Button } from "@/components/keyline/ui/button"
import { Field, FieldAffix, FieldDescription, FieldError, FieldFrame, FieldInput, FieldLabel, FieldTextarea, Form, FormActions } from "@/components/keyline/ui/field"
const IN_STOCK = 480
export function NewOrder() {
const [placed, setPlaced] = useState<string | null>(null)
return (
<Form
orientation="responsive"
onFormSubmit={(v) => {
const cases = Number(v.cases)
setPlaced(`Order DM-4142 placed: ${cases} ${cases === 1 ? "case" : "cases"} for ${v.client}. Darryl has the note.`)
}}
>
<Field name="client">
<FieldLabel>Client</FieldLabel>
<FieldFrame>
<FieldInput required defaultValue="Scranton Business Park" autoComplete="organization" />
</FieldFrame>
<FieldError match="valueMissing">Who is the order for?</FieldError>
</Field>
<Field name="cases" validate={(value) => (Number(value) > IN_STOCK ? `The warehouse has ${IN_STOCK} cases. Split the order or ask Darryl.` : null)}>
<FieldLabel>Quantity</FieldLabel>
<FieldFrame>
<FieldInput type="number" inputMode="numeric" min={1} required defaultValue="40" align="end" />
<FieldAffix>cases</FieldAffix>
</FieldFrame>
<FieldDescription>20 lb copy paper, 10 reams to a case.</FieldDescription>
<FieldError />
</Field>
<Field name="note">
<FieldLabel>Delivery note</FieldLabel>
<FieldFrame>
<FieldTextarea rows={3} placeholder="Loading dock is round the back." />
</FieldFrame>
<FieldDescription>Darryl reads this before he loads the truck.</FieldDescription>
</Field>
<FormActions>
<Button type="submit" variant="primary">
Place order
</Button>
</FormActions>
<p role="status" data-kl-span="control" className="min-h-5 text-[14px] text-kl-ink-2">
{placed ?? "Try 500 cases, or clear the client."}
</p>
</Form>
)
}Installation
npx shadcn@latest add @keyline-ui/fieldRun it where your components.json is. The first time, map the @keyline-ui registry: one command, in Installation.
Usage
import { Form, FormActions, Field, FieldLabel, FieldFrame, FieldInput, FieldError } from "@/components/keyline/ui/field"<Form onFormSubmit={placeOrder}>
<Field name="client">
<FieldLabel>Client</FieldLabel>
<FieldFrame>
<FieldInput required />
</FieldFrame>
<FieldError match="valueMissing">Who is the order for?</FieldError>
</Field>
<FormActions>
<Button type="submit" variant="primary">Place order</Button>
</FormActions>
</Form>Examples
Server errors
Pass your server's answer to errors, keyed by field name, and it shows under that field. The Track button is a layout="cell" Button inside the frame.
Loading the live example…
"use client"
import { useState } from "react"
import { Button } from "@/components/keyline/ui/button"
import { Field, FieldDescription, FieldError, FieldFrame, FieldInput, FieldLabel, Form } from "@/components/keyline/ui/field"
// stands in for your API: only today's shipments exist
const SHIPMENTS: Record<string, string> = {
"DM-4102": "On Darryl's truck, due at Scranton Business Park by 2 PM.",
"DM-4087": "Delivered to the Wilkes-Barre Library at 10:14 AM.",
}
export function TrackShipment() {
const [errors, setErrors] = useState<Record<string, string>>({})
const [found, setFound] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
return (
<Form
className="max-w-[440px]"
errors={errors}
onFormSubmit={(v) => {
const id = String(v.tracking).trim().toUpperCase()
setBusy(true)
setTimeout(() => {
setBusy(false)
if (SHIPMENTS[id]) {
setErrors({})
setFound(`${id}: ${SHIPMENTS[id]}`)
} else {
setFound(null)
setErrors({ tracking: `No shipment ${id} today. Try DM-4102.` })
}
}, 600)
}}
>
<Field name="tracking">
<FieldLabel>Tracking number</FieldLabel>
<FieldFrame>
<FieldInput required defaultValue="DM-9999" spellCheck={false} className="font-kl-mono" />
<Button type="submit" layout="cell" signal={busy ? "pending" : "idle"}>
Track
</Button>
</FieldFrame>
<FieldDescription>Printed on the packing slip.</FieldDescription>
<FieldError />
</Field>
<p role="status" className="m-0 min-h-5 text-[14px] text-kl-ink-2">
{found}
</p>
</Form>
)
}Several parts
FieldGroup puts several labelled inputs in one frame. A rule across them goes in FieldGroupError, and invalid marks the part that broke it.
Loading the live example…
"use client"
import { useState, type ChangeEvent } from "react"
import { FieldGroup, FieldGroupError, FieldGroupJoin, FieldGroupPart } from "@/components/keyline/ui/field"
const SHELF = 30 // inches under the truck's top shelf
export function CartonSize() {
const [size, setSize] = useState({ l: "24", w: "18", h: "34" })
const tall = Number(size.h) > SHELF
const part = (k: keyof typeof size) => ({
inputMode: "numeric" as const,
value: size[k],
onChange: (e: ChangeEvent<HTMLInputElement>) => setSize({ ...size, [k]: e.target.value }),
})
return (
<div className="grid max-w-[360px] gap-2">
<FieldGroup legend="Carton size, inches" invalid={tall}>
<FieldGroupPart label="L" inputProps={part("l")} />
<FieldGroupJoin />
<FieldGroupPart label="W" inputProps={part("w")} />
<FieldGroupJoin />
<FieldGroupPart label="H" invalid={tall} inputProps={part("h")} />
</FieldGroup>
<FieldGroupError>{tall ? `At ${size.h} in, it won't fit under the truck's top shelf. Keep it to ${SHELF} in.` : null}</FieldGroupError>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | "vertical" | "horizontal" | "responsive" | Default "vertical" | On Form or a single Field. Responsive puts labels in a start column while the form is at least 560px wide. |
onFormSubmit | (values) => void | Default — | On Form: runs once every field passes, with the values keyed by each field's name. |
errors | Record<string, string> | Default — | On Form: messages from your server, keyed by field name. They show in that field's FieldError. |
validate | (value) => string | null | Default — | On Field: return a message to fail. Runs on submit, then as the value changes. |
invalid | boolean | Default false | On Field: show the error state yourself, for a rule you check elsewhere. |
match | boolean | keyof ValidityState | Default — | On FieldError: show this message for one check only, such as valueMissing. Without it, the failed check's own message shows. |
layout | "control" | "cell" | Default "control" | On FieldFrame: cell drops its border, to sit inside another frame. |
Also exported: FieldTextarea, FieldAffix for a unit or icon cell, FieldBar for a row across the frame, FieldDescription, and the FieldGroup parts. Built on Base UI Field and Form.