Skip to content

Creating custom fields

Use a custom field when the built-in props do not cover your control—for example, a text input with an explicit accessible label and maxLength. asCommandFormField connects the control to the surrounding form. It does not turn HTML attributes into command rules.

This is a complete fields/MyField.tsx module. It converts native change events to strings and forwards the form’s blur callback, so the default blur-validation behavior still runs.

import { useId, type ChangeEvent } from 'react';
import { asCommandFormField, type WrappedFieldProps } from '@cratis/arc.react/commands';
interface MyFieldProps extends WrappedFieldProps<string> {
label: string;
placeholder?: string;
maxLength?: number;
}
export const MyField = asCommandFormField<MyFieldProps>(
function TextInput(props: MyFieldProps) {
const generatedId = useId();
const id = props.id ?? generatedId;
return (
<div>
<label htmlFor={id}>{props.label}</label>
<input
id={id}
value={props.value ?? ''}
onChange={props.onChange}
onBlur={props.onBlur}
required={props.required}
aria-invalid={props.invalid}
placeholder={props.placeholder}
maxLength={props.maxLength}
/>
</div>
);
},
{
defaultValue: '',
extractValue: (event: unknown) => (event as ChangeEvent<HTMLInputElement>).target.value
}
);

The extractor above expects a native input change event because that is the only event this control emits. If your control also emits values directly, handle both shapes explicitly. The component lets CommandForm render messages, rather than rendering the same errors a second time.

Use a generated command class as the constructor. This complete form uses UpdateProfile from the overview, not an interface named like a command:

import { CommandForm } from '@cratis/arc.react/commands';
import { UpdateProfile } from './commands/UpdateProfile';
import { MyField } from './fields/MyField';
export function ProfileForm() {
return (
<CommandForm command={UpdateProfile} initialValues={{ name: '', email: '' }}>
<MyField<UpdateProfile> value={c => c.name} label="Name" maxLength={100} />
<MyField<UpdateProfile> value={c => c.email} label="Email" />
<button type="submit">Save</button>
</CommandForm>
);
}

No title is supplied because each control renders its own label. If you use a form-rendered title instead, forward props.id to the control; do not render a second label with the same text. maxLength constrains normal typing but does not validate programmatically supplied values. CommandForm uses noValidate; keep length/format/required rules on the command.

PropTypeContract
valueTValueCurrent display value; configured fallback is used only for undefined, not null.
idstring | undefinedForward to the rendered input for a single-input field, or to the group container for a grouped field. A caller-supplied id takes precedence over the generated one.
onChange(valueOrEvent: TValue | unknown) => voidForward the control’s change event/value for extraction and binding.
onBlur(() => void) | undefinedForward to the control’s blur event to preserve form blur validation.
invalidbooleanWhether this field has a displayed error.
requiredbooleanExplicit control override or inferred descriptor flag, not a new command rule.
errorsstring[]Messages made available to the adapter; rendering them yourself is optional.

The configuration requires defaultValue: TValue and optionally accepts extractValue: (event: unknown) => TValue. Without an extractor the emitted value is used directly. A fallback only changes display; it does not seed the command or its baseline. Define a deliberate null/empty policy for numeric/date controls rather than silently converting every empty value to a business value.

For a field with multiple inputs, set groupRole: 'group' (or 'radiogroup' for radio options) in the asCommandFormField configuration alongside defaultValue and any extractValue. CommandForm then renders the title as the group’s accessible name, not as a label for the first option. Forward props.id to the group container if it needs an id; never put it on one of the options. Label each option individually. The form-rendered title is omitted when showTitles={false}; in that case provide an accessible group name yourself. Existing single-input adapters need no configuration change.

asCommandFormField marks the returned component with a static isCommandFormField property. Its displayName is CommandFormField as a compatibility fallback. It also binds itself when an opaque layout creates or transforms the field at render time, so custom layouts can preserve the original element type and change its presentation/binding props before registration.

Binding uses a private framework marker, not the presence of an onValueChange prop. A layout can wrap onValueChange without disabling binding; the form updates the command before invoking that consumer callback. Do not treat that callback’s presence as proof of prior binding.

For a hand-written adapter, prefer withCommandFormFieldBinding over marker-only registration. This complete alternative module includes the injected blur contract:

import {
withCommandFormFieldBinding,
type CommandFormFieldProps,
type InjectedCommandFormFieldProps
} from '@cratis/arc.react/commands';
type HandRolledProps = CommandFormFieldProps & InjectedCommandFormFieldProps;
export const HandRolledField = withCommandFormFieldBinding((props: HandRolledProps) => (
<input
id={props.id}
aria-label={props.title}
value={String(props.currentValue ?? '')}
onChange={event => props.onValueChange?.(event.target.value)}
onBlur={props.onBlur}
required={props.required}
/>
));

Supply a title when using that adapter to provide its accessible name. markAsCommandFormField remains supported for compatibility through visible child trees; withCommandFormFieldBinding is the choice for fields crossing custom component boundaries. Neither replaces error rendering by the surrounding form.

This optional integration module, fields/PrimeInputTextField.tsx, needs a compatible installed PrimeReact package and its theme setup. It does not require Chronicle or make PrimeReact an Arc dependency.

import { useId, type ChangeEvent } from 'react';
import { InputText, type InputTextProps } from 'primereact/inputtext';
import { asCommandFormField, type WrappedFieldProps } from '@cratis/arc.react/commands';
interface PrimeInputTextFieldProps extends WrappedFieldProps<string> {
label: string;
placeholder?: InputTextProps['placeholder'];
maxLength?: InputTextProps['maxLength'];
type?: InputTextProps['type'];
}
export const PrimeInputTextField = asCommandFormField<PrimeInputTextFieldProps>(
function PrimeTextInput(props: PrimeInputTextFieldProps) {
const generatedId = useId();
const { label, value, onChange, invalid, required, errors, id: providedId, ...rest } = props;
const id = providedId ?? generatedId;
void errors;
return (
<div>
<label htmlFor={id}>{label}</label>
<InputText
id={id}
value={value ?? ''}
onChange={onChange}
invalid={invalid}
required={required}
style={{ width: '100%' }}
{...rest}
/>
</div>
);
},
{
defaultValue: '',
extractValue: (event: unknown) => (event as ChangeEvent<HTMLInputElement>).target.value
}
);

onBlur deliberately remains in ...rest, which is spread onto InputText; blur is forwarded. If you destructure it later, add onBlur={onBlur} explicitly. Do not remove that forwarding. The adapter excludes errors from the input and leaves messages to the form to avoid duplicate error text.

This complete usage component supplies the real command constructor and all adapter-required props:

import { CommandForm } from '@cratis/arc.react/commands';
import { UpdateProfile } from './commands/UpdateProfile';
import { PrimeInputTextField } from './fields/PrimeInputTextField';
export function ProfileForm() {
return (
<CommandForm command={UpdateProfile} initialValues={{ name: '', email: '' }}>
<PrimeInputTextField<UpdateProfile> value={c => c.name} label="Name" maxLength={100} />
<PrimeInputTextField<UpdateProfile> value={c => c.email} label="Email" type="email" />
<button type="submit">Save</button>
</CommandForm>
);
}

Apply the same adapter contract to rich text, numeric inputs, and switches:

  • Text area: forward onChange and onBlur; extract ChangeEvent<HTMLTextAreaElement>.target.value. Add rows/maxLength only if your adapter forwards them.
  • PrimeReact InputNumber: use its onValueChange event and extract its value, deciding explicitly what null means. Forward onBlur and any required/invalid attributes supported by your installed version.
  • Switch: bind checked, extract the library’s boolean value, and forward onBlur. A required flag does not require the value to be true; consent needs a command rule.

These are adapter design notes, not drop-in components. Check the installed control’s prop/event definitions before implementing an adapter; a wrapper cannot safely assume all libraries emit native events.

Supply the command type in JSX (<MyField<UpdateProfile> ... />) or annotate the accessor parameter. This checks property names. Most generic field accessors return unknown, so a valid property name alone does not prove its value type matches the control; do not bind a Date or number to a string adapter without an explicit conversion policy.

Forward blur as well as change. Choose one owner for error display; custom fields that render errors themselves must opt out of the form’s display to avoid duplicates, and showErrors={false} does not suppress an adapter’s own markup. Associate labels and messages with controls in your adapter, test keyboard navigation, and keep blocking rules on the command.

Export the adapters you actually implement from your application’s field module. Keep generated commands in the configured proxy output, and import those classes when constructing forms. Do not substitute application interfaces for executable command constructors.