A custom field is a two-sided protocol contract: a server Field subclass emits a descriptor type, and a UI plugin registers a component for that exact type. Use a vendor prefix; adonia/* is reserved.
This example builds acme/currency on the existing textual field behavior, so validation, hydration, dehydration, visibility, fill allowlisting, and reactive rules remain framework-owned.
1. Define the server field
import { TextInput } from '@adonia/core'
export class CurrencyField extends TextInput {
static override type = 'acme/currency'
static override displayType = 'text-entry'
static override columnType = 'number'
static override make(attribute: string): CurrencyField {
return new CurrencyField(attribute)
}
currency(code: string): this {
return this.withProps({ currency: code })
}
}
export const AcmeFields = {
currency: (attribute: string) => CurrencyField.make(attribute),
}A field subclass must provide a stable static type and validation behavior. Extending a built-in is preferable when its state and Vine mapping already match. For a new state shape, extend Field<TValue> and implement vineSchema; also decide displayType, columnType, default/hydrate/dehydrate behavior, and whether the field can derive a table column.
Use the field in a resource schema like every built-in:
import { BaseResource, type SchemaBuilder, type SchemaComponent } from '@adonia/core'
import Post from '#models/post'
import { AcmeFields } from '#adonia/fields/currency_field'
export default class PostResource extends BaseResource<typeof Post> {
static override model = Post
static override slug = 'posts'
override schema(_schema: SchemaBuilder): SchemaComponent[] {
return [AcmeFields.currency('price').label('Price').currency('USD').required()]
}
}Only JSON-safe values may enter the descriptor. withProps() writes the custom currency prop while inherited calls such as label, required, readonlyOn, visibleWhen, disabledWhen, and dependsOn retain their normal semantics.
2. Register the client renderer
import type { FieldProps, NamedAdoniaUiPlugin } from '@adonia/ui'
function CurrencyInput({ node, control }: FieldProps) {
const label = String(node.props.label ?? node.key)
const currency = String(node.props.currency ?? '')
const value = typeof control.value === 'string' ? control.value : ''
return (
<label>
<span>{label}</span>
<span>{currency}</span>
<input
name={node.key ?? undefined}
inputMode="decimal"
value={value}
disabled={control.disabled ?? undefined}
aria-invalid={control.error === null ? undefined : true}
onChange={(event) => control.setValue(event.currentTarget.value)}
/>
{control.error === null ? null : <span role="alert">{control.error}</span>}
</label>
)
}
export const acmeFieldsUi: NamedAdoniaUiPlugin = {
name: '@acme/adonia-fields',
version: '1.0.0',
protocolVersion: 1,
register(registry) {
registry.field('acme/currency', CurrencyInput)
},
}Install the UI half through the application registry:
import { adonia } from '@adonia/ui'
import { acmeFieldsUi } from '#adonia/fields/currency_input'
export default adonia({
registry(registry) {
registry.use(acmeFieldsUi)
},
})The component is controlled by FieldController: read control.value, write through control.setValue, honor disabled, and expose error. Do not keep a second authoritative state. The server still decides validation and fill allowlists.
3. Cover every projection
A form field type does not automatically create a detail entry, table cell, or filter. This example deliberately reuses text-entry and number; a genuinely new projection must register its matching maps with registry.cell, registry.filter, or another explicit registry method and emit matching server descriptors.
Unknown types do not crash the page, but their warning and telemetry are a deployment fault signal, not a fallback design. Version server and client halves together and install both before emitting the type.
4. Verify the contract
Test the observable round trip: descriptor type/props, create and edit hydration, validation rejection, disabled/hidden fill omission, persisted value, detail projection, and keyboard/screen-reader behavior. For reactive fields, run the server/client mirroring fixtures from @adonia/core/testing. The accessibility report defines the shared component obligations.
Built-in field pages under Field types are generated from source TSDoc and the field roster; do not edit those generated pages. The complete public symbols are in the API reference.