Skip to main content
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:
  1. Calls resolveFieldComponent(schema) to check the registry
  2. If a registered matcher returns true, renders the registered component
  3. 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:
SchemaRenders 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: trueCheckbox 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:
SchemaImport pathRegistration function
format: "json" or format: "code"@flowdrop/flowdrop/form/coderegisterCodeEditorField(fd.fields)
format: "template"@flowdrop/flowdrop/form/coderegisterTemplateEditorField(fd.fields)
format: "markdown"@flowdrop/flowdrop/form/markdownregisterMarkdownEditorField(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'.