Custom fields
A custom field component must implement a single interface:
defineProps<{
modelValue: unknown;
descriptor: CustomDescriptor; // or the specific built-in descriptor you override
errors?: string[];
readonly?: boolean;
disabled?: boolean;
}>();
defineEmits<{ "update:modelValue": [value: unknown] }>();Nothing else. Idiomatic Vue.
Which descriptor type?
- A field with a new custom
typereceives aCustomDescriptor(openattrs). - A component that overrides a built-in type (e.g. a Vuetify text input replacing
text) should declare the specific descriptor (TextDescriptor,SelectDescriptor, …) to get typedattrs. - Pages that mix custom types into their
fieldsarray annotate it withAnyFieldDescriptor<T>instead of the strictFieldDescriptor<T>.
Example: VideoCutField
<!-- components/admin/VideoCutField.vue -->
<script setup lang="ts">
import type { CustomDescriptor } from "mapomodule/types";
interface VideoCut {
url: string;
start: number;
end: number;
}
defineProps<{
modelValue: VideoCut | null;
descriptor: CustomDescriptor;
errors?: string[];
readonly?: boolean;
}>();
const emit = defineEmits<{ "update:modelValue": [value: VideoCut | null] }>();
</script>
<template>
<div class="space-y-2">
<UInput
:model-value="modelValue?.url"
placeholder="Video URL"
:readonly="readonly"
@update:model-value="
emit('update:modelValue', { ...modelValue, url: $event })
"
/>
<!-- ... range picker for start/end ... -->
<p v-for="err in errors" :key="err" class="text-sm text-red-500">
{{ err }}
</p>
</div>
</template>Registration
Recommended: defineFormField() from a plugin
defineFormField() is the typed extension API. It registers the component into the global $mapoFormRegistry, optionally supplies default attrs and an accessor, and warns if you accidentally re-register an existing type.
// app/plugins/my-fields.ts
export default defineNuxtPlugin(() => {
defineFormField(
"video-cut",
() => import("~/components/admin/VideoCutField.vue"),
{
attrs: { aspectRatio: "16:9" },
},
);
});Plugins inside app/plugins/ run after module plugins, so $mapoFormRegistry is always available. Pass { override: true } to silence the collision warning when you intentionally replace a built-in type.
Per-page override (custom registry)
Sometimes you only want to swap a field for a single page. Build a custom registry and pass it explicitly:
// pages/videos/[id].vue
const { $mapoFormRegistry } = useNuxtApp();
import VideoCutField from "~/components/admin/VideoCutField.vue";
const registry = {
...$mapoFormRegistry,
mapping: { ...$mapoFormRegistry.mapping, "video-cut": VideoCutField },
};<MapoForm :registry="registry" ... />When you do not pass :registry, <MapoForm> falls back to $mapoFormRegistry.
Per-field override (descriptor.is)
import VideoCutField from "~/components/admin/VideoCutField.vue";
const fields = [{ key: "cut", type: "video-cut", is: VideoCutField }];descriptor.is bypasses the registry entirely for that one field.
Accessing the form context from a field
If your custom component needs the full model or the active language:
// inside your custom field
const form = injectMapoForm();
// form?.model.value → full model
// form?.currentLang.value → active language
// form?.languages → list of languagesinjectMapoForm is auto-imported when @mapomodule/form is installed.
Tip: reusing built-in Mapo fields
A custom field can compose other Mapo fields internally:
<template>
<div>
<UInput v-model="localTitle" />
<!-- MapoFksField used as a sub-component -->
<MapoFksField
:model-value="modelValue?.author"
:descriptor="{
key: 'author',
type: 'fks',
attrs: { endpoint: '/api/authors/' },
}"
@update:model-value="
emit('update:modelValue', { ...modelValue, author: $event })
"
/>
</div>
</template>
MapoFksFieldis auto-imported because the module registers it as a global component.