FlowDrop generates configuration forms automatically from JSON Schema. The field registry system lets you add custom field components — for example a color picker, date picker, or rich text editor.
Quick start
1. Write a Svelte field component:
<!-- ColorPickerField.svelte -->
<script lang="ts">
interface Props {
id: string;
value: unknown;
onChange: (value: unknown) => void;
}
let { id, value, onChange }: Props = $props();
</script>
<input
{id}
type="color"
value={String(value ?? '#000000')}
oninput={(e) => onChange(e.currentTarget.value)}
/>
2. Register it:
import { getInstance } from '@flowdrop/flowdrop/editor';
import ColorPickerField from './ColorPickerField.svelte';
const fd = getInstance(); // or app.instance outside the component tree
fd.fields.register('color-picker', {
component: ColorPickerField,
matcher: (schema) => schema.format === 'color',
priority: 100
});
3. Use it in a config schema:
{
"accentColor": {
"type": "string",
"format": "color",
"title": "Accent Color",
"default": "#3b82f6"
}
}
How it works
When FormFieldLight renders a field, it:
- Calls
resolveFieldComponent(schema) to check the registry
- If a registered matcher returns
true, renders the registered component
- Otherwise falls back to built-in fields (text, number, toggle, select, etc.)
Registrations are priority-ordered — higher priority matchers are checked first.
Field component props
Your component receives these props:
interface Props {
id: string;
value: unknown;
placeholder?: string;
required?: boolean;
ariaDescribedBy?: string;
onChange: (value: unknown) => void;
}
Only the props listed above are guaranteed. Read any additional schema properties (like schema.minDate) directly from the schema via the form context — see Reading sibling field values below.
Matcher functions
A matcher decides whether your component handles a given schema:
// Match by format
(schema) => schema.format === "color"
// Match by type + format
(schema) => schema.type === "string" && schema.format === "rich-text"
// Match by custom property
(schema) => schema.widget === "my-widget"
Priority-based resolution
When multiple registrations match, the highest priority wins:
// Priority 50 — general fallback
fd.fields.register('text-basic', {
component: BasicTextField,
matcher: (schema) => schema.type === 'string',
priority: 50
});
// Priority 100 — more specific, checked first
fd.fields.register('rich-text', {
component: RichTextField,
matcher: (schema) => schema.type === 'string' && schema.format === 'rich-text',
priority: 100
});
You can use this to override built-in fields by registering your own component with a higher priority.
Lazy registration
For heavy dependencies, use dynamic imports:
import type { FieldComponentRegistry } from '@flowdrop/flowdrop/form';
export function registerMyHeavyField(fields: FieldComponentRegistry, priority = 100): void {
if (fields.has('my-heavy-field')) return;
import('./MyHeavyField.svelte').then((module) => {
fields.register('my-heavy-field', {
component: module.default,
matcher: (schema) => schema.format === 'heavy',
priority
});
});
}
// Call with the instance's registry: registerMyHeavyField(getInstance().fields)
Built-in field types
These fields are always available without registration:
| Schema | Renders as |
|---|
type: "string" | Text input |
type: "string", format: "multiline" | Textarea |
type: "number" or type: "integer" | Number input |
type: "number", format: "range" | Range slider |
type: "boolean" | Toggle switch |
type: "string", enum: [...] | Select dropdown |
type: "string", enum: [...], multiple: true | Checkbox group |
type: "string", oneOf: [{const, title}] | Select with labeled options |
type: "array", items: {...} | Dynamic list |
format: "hidden" | Hidden (not rendered) |
These require explicit registration (heavy dependencies):
Each installer takes the target field registry (fd.fields) as its first argument:
| Schema | Import path | Registration function |
|---|
format: "json" or format: "code" | @flowdrop/flowdrop/form/code | registerCodeEditorField(fd.fields) |
format: "template" | @flowdrop/flowdrop/form/code | registerTemplateEditorField(fd.fields) |
format: "markdown" | @flowdrop/flowdrop/form/markdown | registerMarkdownEditorField(fd.fields) |
Field management
Field management is done through the instance’s fd.fields registry (a FieldComponentRegistry):
import { getInstance } from '@flowdrop/flowdrop/editor';
const fd = getInstance(); // or app.instance outside the component tree
fd.fields.unregister('color-picker'); // returns boolean
fd.fields.getKeys(); // ["color-picker", ...]
fd.fields.has('color-picker'); // true or false
fd.fields.size; // number of registrations
fd.fields.clear(); // clear all (useful in tests)
Reading sibling field values
Custom components registered for format: "autocomplete" fields receive the full schema object as a prop and can read the current values of other fields in the same form using the FORM_VALUES_KEY context.
This is the building block for dependent autocomplete fields — for example a project field whose suggestions depend on the currently selected account.
1. Define the schema — use any custom property to declare the dependency:
{
"account": { "type": "string", "title": "Account" },
"project": {
"type": "string",
"format": "autocomplete",
"title": "Project",
"autocomplete": { "url": "/api/projects", "labelField": "name", "valueField": "id" },
"dependencies": { "account": "account" }
}
}
2. Write the component — wrap FormAutocomplete and patch the URL:
<!-- DependentAutocomplete.svelte -->
<script lang="ts">
import { getContext } from 'svelte';
import { FormAutocomplete } from '@flowdrop/flowdrop/form/autocomplete';
import {
FORM_VALUES_KEY,
type FormValuesGetter,
type FieldSchema
} from '@flowdrop/flowdrop/form';
import type { AutocompleteConfig } from '@flowdrop/flowdrop/form';
interface Props {
id: string;
value: unknown;
schema: FieldSchema;
autocomplete: AutocompleteConfig;
placeholder?: string;
required?: boolean;
disabled?: boolean;
ariaDescribedBy?: string;
onChange: (value: unknown) => void;
}
let { schema, autocomplete, ...rest }: Props = $props();
const getFormValues = getContext<FormValuesGetter | undefined>(FORM_VALUES_KEY);
const patchedAutocomplete = $derived.by(() => {
const values = getFormValues?.() ?? {};
const deps = (schema as any).dependencies as Record<string, string> | undefined;
if (!deps) return autocomplete;
const extra = Object.entries(deps)
.filter(([, field]) => values[field] != null && values[field] !== '')
.map(
([param, field]) =>
`${encodeURIComponent(param)}=${encodeURIComponent(String(values[field]))}`
)
.join('&');
const sep = autocomplete.url.includes('?') ? '&' : '?';
return { ...autocomplete, url: extra ? `${autocomplete.url}${sep}${extra}` : autocomplete.url };
});
</script>
<FormAutocomplete autocomplete={patchedAutocomplete} {...rest} />
3. Register it — match on the custom dependencies property:
import { getInstance } from '@flowdrop/flowdrop/editor';
import DependentAutocomplete from './DependentAutocomplete.svelte';
const fd = getInstance(); // or app.instance outside the component tree
fd.fields.register('dependent-autocomplete', {
component: DependentAutocomplete,
matcher: (schema) => schema.format === 'autocomplete' && 'dependencies' in schema,
priority: 150
});
The registered component is only activated when a schema has both format: "autocomplete" and a dependencies property. All other autocomplete fields continue to use FlowDrop’s built-in FormAutocomplete.
FormAutocomplete is a named export.Import it with import { FormAutocomplete } from '@flowdrop/flowdrop/form/autocomplete'.