Data Entry
Patterns for efficient and accurate data input — from inline validation to multi-step wizards and bulk editing. Minimize errors and maximize completion rates.
Form with inline validation
Validate input as users type and provide immediate, specific error messages. Show success states when input is valid.
Multi-step wizard
Break complex data entry into focused steps. Validate each step before allowing progress, and show clear navigation controls.
Personal information
Bulk data entry
For entering multiple records at once, provide a spreadsheet-like interface with inline editing and quick-add rows.
| Item | Quantity | Price | |
|---|---|---|---|
Entry patterns
Inline validation
Validate as you type with clear error messages and helpful suggestions
Stepped wizard
Break complex forms into logical steps with progress indicators
Bulk editing
Edit multiple records in a spreadsheet-like interface for efficiency
Smart defaults
Pre-fill with sensible defaults and remember user preferences
Implementation
"use client";
import { useState } from "react";
import { Input } from "@/components/ui/Input";
import { Select } from "@/components/ui/Select";
import { Button } from "@/components/ui/Button";
import { DatePicker } from "@/components/date-time-pickers";
import { cn } from "@/lib";
// ── Form with inline validation ─────────────────────────────
interface FormField<T = string> {
name: string;
label: string;
type: "text" | "email" | "password" | "textarea" | "select" | "date" | "number";
value: T;
error?: string;
options?: { value: string; label: string }[];
required?: boolean;
}
interface DataEntryFormProps {
fields: FormField[];
onSubmit: (data: Record<string, any>) => void;
submitLabel?: string;
}
export function DataEntryForm({ fields, onSubmit, submitLabel = "Save" }: DataEntryFormProps) {
const [formFields, setFormFields] = useState(fields);
function updateField(name: string, value: any) {
setFormFields(prev =>
prev.map(f => {
if (f.name !== name) return f;
const error = validate(f, value);
return { ...f, value, error };
})
);
}
function validate(field: FormField, value: any): string | undefined {
if (field.required && !value?.toString().trim()) {
return field.label + " is required";
}
if (field.type === "email" && value && !/^[^@]+@[^@]+.[^@]+$/.test(value)) {
return "Enter a valid email address";
}
return undefined;
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
let hasError = false;
const validated = formFields.map(f => {
const error = validate(f, f.value);
if (error) hasError = true;
return { ...f, error };
});
setFormFields(validated);
if (!hasError) {
const data = validated.reduce((acc, f) => ({ ...acc, [f.name]: f.value }), {});
onSubmit(data);
}
}
return (
<form onSubmit={handleSubmit} noValidate className="space-y-5">
{formFields.map((field) => (
<div key={field.name}>
<label className="block text-[12px] font-medium text-[rgb(var(--text-secondary))] mb-2">
{field.label}
{field.required && <span className="text-[rgb(var(--accent))] ml-0.5">*</span>}
</label>
{field.type === "textarea" ? (
<textarea
value={field.value as string}
onChange={(e) => updateField(field.name, e.target.value)}
onBlur={() => updateField(field.name, field.value)}
rows={3}
className={cn(
"w-full rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--surface))]",
"px-3 py-2 text-[13px] text-[rgb(var(--text-primary))]",
"focus:border-[rgb(var(--accent))] focus:ring-2 focus:ring-[rgb(var(--accent))]/20",
"transition-colors resize-none",
field.error && "border-[#f87171] focus:ring-[#f87171]/20"
)}
placeholder={"Enter " + field.label.toLowerCase() + "..."}
/>
) : field.type === "select" ? (
<select
value={field.value as string}
onChange={(e) => updateField(field.name, e.target.value)}
className={cn(
"w-full rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--surface))]",
"px-3 py-2 text-[13px] text-[rgb(var(--text-primary))] cursor-pointer",
"focus:border-[rgb(var(--accent))] focus:ring-2 focus:ring-[rgb(var(--accent))]/20",
"appearance-none",
field.error && "border-[#f87171]"
)}
>
<option value="">Select an option</option>
{field.options?.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
) : field.type === "date" ? (
<div>
<Input
type="date"
value={field.value as string}
onChange={(e) => updateField(field.name, e.target.value)}
error={field.error}
required={field.required}
/>
</div>
) : (
<Input
type={field.type}
value={field.value as string}
onChange={(e) => updateField(field.name, e.target.value)}
onBlur={() => updateField(field.name, field.value)}
error={field.error}
required={field.required}
placeholder={"Enter " + field.label.toLowerCase() + "..."}
/>
)}
{field.error && (
<p className="flex items-center gap-1.5 mt-2 text-[12px] text-[#f87171]">
<svg className="w-3 h-3" viewBox="0 0 12 12" fill="none">
<circle cx="6" cy="6" r="5" stroke="currentColor" strokeWidth="1.5"/>
<line x1="6" y1="3" x2="6" y2="6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
{field.error}
</p>
)}
</div>
))}
<Button type="submit" variant="primary" className="w-full">
{submitLabel}
</Button>
</form>
);
}
// ── Stepped data entry w/ progress ─────────────────────────
interface Step {
title: string;
fields: FormField[];
}
export function SteppedDataEntry({
steps,
onComplete,
}: {
steps: Step[];
onComplete: (data: Record<string, any>) => void;
}) {
const [currentStep, setCurrentStep] = useState(0);
const [stepData, setStepData] = useState<Record<string, any>[]>(steps.map(() => ({})));
function updateStepData(field: FormField, value: any) {
setStepData(prev => {
const next = [...prev];
next[currentStep] = { ...next[currentStep], [field.name]: value };
return next;
});
}
function next() {
if (currentStep < steps.length - 1) setCurrentStep(prev => prev + 1);
else {
const allData = stepData.reduce((acc, data, i) => {
steps[i].fields.forEach(f => { acc[f.name] = data[f.name]; });
return acc;
}, {} as Record<string, any>);
onComplete(allData);
}
}
function back() {
if (currentStep > 0) setCurrentStep(prev => prev - 1);
}
return (
<div className="max-w-lg mx-auto">
{/* Progress indicator */}
<div className="flex items-center gap-2 mb-8">
{steps.map((step, i) => (
<div key={step.title} className="flex items-center flex-1">
<div className={cn(
"w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-bold transition-all",
i < currentStep ? "bg-[rgb(var(--accent))] text-white" :
i === currentStep ? "bg-[rgb(var(--accent))] text-white ring-4 ring-[rgb(var(--accent))]/20" :
"bg-[rgb(var(--surface-raised))] text-[rgb(var(--text-tertiary))] border border-[rgb(var(--border))]"
)}>
{i < currentStep ? "✓" : i + 1}
</div>
{i < steps.length - 1 && (
<div className={cn("flex-1 h-px mx-2", i < currentStep ? "bg-[rgb(var(--accent))]" : "bg-[rgb(var(--border))]")} />
)}
</div>
))}
</div>
{/* Step content */}
<div className="rounded-xl border border-[rgb(var(--border))] bg-[rgb(var(--surface))] p-6">
<h3 className="text-[16px] font-semibold text-[rgb(var(--text-primary))] mb-6">{steps[currentStep].title}</h3>
<div className="space-y-5">
{steps[currentStep].fields.map((field) => (
<div key={field.name}>
<label className="block text-[12px] font-medium text-[rgb(var(--text-secondary))] mb-2">
{field.label}
{field.required && <span className="text-[rgb(var(--accent))] ml-0.5">*</span>}
</label>
<Input
type={field.type === "number" ? "number" : "text"}
value={stepData[currentStep][field.name] || ""}
onChange={(e) => updateStepData(field, e.target.value)}
placeholder={field.label}
/>
</div>
))}
</div>
<div className="flex items-center justify-between mt-6 pt-4 border-t border-[rgb(var(--border-subtle))]">
<button
onClick={back}
disabled={currentStep === 0}
className={cn(
"text-[12px] font-medium transition-colors",
currentStep === 0
? "text-[rgb(var(--text-tertiary))] cursor-not-allowed"
: "text-[rgb(var(--accent))] hover:underline"
)}
>
Back
</button>
<button
onClick={next}
className="px-4 py-2 rounded-lg bg-[rgb(var(--accent))] text-white text-[12px] font-medium hover:bg-[rgb(var(--accent))]/90 transition-colors"
>
{currentStep === steps.length - 1 ? "Complete" : "Continue"}
</button>
</div>
</div>
</div>
);
}
// ── Bulk entry table ───────────────────────────────────────
interface BulkEntryProps<T = Record<string, any>> {
columns: { key: string; label: string; type: "text" | "number" | "select" }[];
rows: T[];
onChange: (rows: T[]) => void;
addRowLabel?: string;
}
export function BulkEntryTable<T extends Record<string, any>>({
columns,
rows,
onChange,
addRowLabel = "Add row",
}: BulkEntryProps<T>) {
const updateCell = (rowIndex: number, colKey: string, value: any) => {
const newRows = [...rows];
newRows[rowIndex] = { ...newRows[rowIndex], [colKey]: value };
onChange(newRows);
};
const addRow = () => {
const newRow = columns.reduce((acc, col) => ({ ...acc, [col.key]: "" }), {} as T);
onChange([...rows, newRow]);
};
const removeRow = (index: number) => {
onChange(rows.filter((_, i) => i !== index));
};
return (
<div className="rounded-xl border border-[rgb(var(--border))] overflow-hidden">
<table className="w-full text-[13px]">
<thead>
<tr className="bg-[rgb(var(--surface-raised))] border-b border-[rgb(var(--border))]">
{columns.map(col => (
<th key={col.key} className="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wider text-[rgb(var(--text-tertiary))]">
{col.label}
</th>
))}
<th className="w-12"></th>
</tr>
</thead>
<tbody>
{rows.map((row, rowIndex) => (
<tr key={rowIndex} className="border-b border-[rgb(var(--border-subtle))] last:border-0 hover:bg-[rgb(var(--surface))]">
{columns.map(col => (
<td key={col.key} className="px-4 py-2">
{col.type === "select" ? (
<select
value={row[col.key] as string}
onChange={(e) => updateCell(rowIndex, col.key, e.target.value)}
className="w-full rounded border border-[rgb(var(--border))] bg-[rgb(var(--surface))] px-2 py-1 text-[12px]"
>
<option value="">Select</option>
<option value="option1">Option 1</option>
<option value="option2">Option 2</option>
</select>
) : (
<input
type={col.type}
value={row[col.key] as string}
onChange={(e) => updateCell(rowIndex, col.key, e.target.value)}
className="w-full bg-transparent text-[12px] outline-none focus:text-[rgb(var(--text-primary))]"
/>
)}
</td>
))}
<td>
<button
onClick={() => removeRow(rowIndex)}
className="p-1 rounded text-[rgb(var(--text-tertiary))] hover:text-[#f87171] transition-colors"
aria-label="Remove row"
>
<Trash2 className="w-3 h-3" />
</button>
</td>
</tr>
))}
</tbody>
</table>
<button
onClick={addRow}
className="w-full py-2 text-[12px] text-[rgb(var(--accent))] font-medium hover:bg-[rgb(var(--surface))] transition-colors flex items-center justify-center gap-1"
>
<Plus className="w-3 h-3" />
{addRowLabel}
</button>
</div>
);
}Accessibility
- →Always provide associated <label> elements for inputs, use aria-label as fallback.
- →Error messages must be linked via aria-describedby and have role='alert' for screen readers.
- →Required fields need aria-required='true' and visually indicate the requirement.
- →After validation errors, move focus to the first error or provide a summary at the top.
- →Use appropriate input types (email, number, tel) to trigger correct virtual keyboards.
- →For multi-step forms, announce step changes with aria-live regions.