FlowDrop Docs

Custom form fields

Extend FlowDrop's form system with custom field components.

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'.

On this page