Custom Fields
Writing and registering a field component that isn't in Prism's own catalog.
A field component's only real obligation
A field component accepts Prism's normalized FieldComponentProps<TValue, TExtraProps> and
forwards field.value/field.onChange/field.onBlur/field.error to whatever it renders. It
never imports react-hook-form, and it can wrap any UI library, not only Prism's own Layer 1
primitives:
import type { FieldComponentProps } from '@prismrepo/forms';
// A field component wrapping a plain native <input>, no Prism primitive at all.
export function PlainTextField({
field,
label,
}: FieldComponentProps<string, { readonly label: string }>) {
return (
<label>
{label}
<input
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
onBlur={field.onBlur}
aria-invalid={field.error !== undefined}
/>
{field.error ? <span role="alert">{field.error}</span> : null}
</label>
);
}A field component with required extra props
A custom field can declare whatever extra props it needs, and defineForm requires them at the
configuration site:
export function RatingField({
field,
max = 5,
}: FieldComponentProps<number, { readonly max?: number }>) {
return (
<div role="radiogroup">
{Array.from({ length: max }, (_, i) => i + 1).map((n) => (
<button
key={n}
type="button"
aria-pressed={field.value === n}
onClick={() => field.onChange(n)}
>
{n <= field.value ? '★' : '☆'}
</button>
))}
{field.error ? <span role="alert">{field.error}</span> : null}
</div>
);
}Field registration
Registering a custom field is identical to registering a catalog field, there's no distinction the type system enforces between "Prism's" and "the consumer's":
import { defineRegistry } from '@prismrepo/forms';
import { TextField } from './text-field'; // CLI-installed
import { RatingField } from './rating-field'; // consumer-authored
export const registry = defineRegistry({
text: TextField,
rating: RatingField,
});const definition = createFormBuilder(registry).defineForm<ReviewValues>({
comment: { type: 'text', label: 'Comment' },
rating: { type: 'rating', max: 5 },
});One inference pitfall to know about
Annotate the parameter with FieldComponentProps<...> written out inline, as in both
examples above, not through a separately-declared type Props = FieldComponentProps<...> used
as the annotation. Doing so silently breaks createFormBuilder's ability to infer that
component's extra props, widening them to include field itself and defeating the
type-checked configuration described in Form Engine Overview.
If you want to export a named Props type anyway, derive it from the function afterward:
export function RatingField({ field, max = 5 }: FieldComponentProps<number, { max?: number }>) {
/* ... */
}
/** Derived from RatingField itself, not a separate declaration. */
export type RatingFieldProps = Parameters<typeof RatingField>[0];Every field template the CLI installs (text-field.tsx, number-field.tsx, and the rest)
follows exactly this pattern.