# Reference

Quick lookup tables. For narrative documentation see the
[Usage guide](usage.md).

**Note:**: the Public API is what is referenced below. There may be additional
functions marked as public within modules. These are for internal use and are
subject to change. If you find yourself needing to use one of these functions,
please open a GitHub issue so we can explore if there's a better solution or
a way to surface it as a stable public function moving forward.

## `DynamicForm.form/1` attributes

| Attribute | Type | Default | Description |
|---|---|---|---|
| `id` | string | required | Component ID; also the instance id in declarative mode |
| `instance` | any | `nil` | Data mode: `Instance` struct, JSON string, or map |
| `json` | string | `nil` | Data mode: SurveyJS-compatible JSON string, parsed via `Parser.JSON.parse!/1` |
| `title` | string | `nil` | Instance title (declarative mode) |
| `description` | string | `nil` | Instance description (declarative mode) |
| `on_change` | function | `nil` | 1-arity `(payload) -> payload`, after built-in validations on every change and during the submit validation pass |
| `change_debounce_in_ms` | integer | `nil` | Milliseconds of quiet before a change runs `on_change` and sends its `:change` message. `nil` and `0` run both on every change |
| `on_submit` | function | `nil` | 1-arity `(payload) -> payload`, on every submit — valid or not |
| `on_success` | function | `nil` | 1-arity `(payload)`, on every valid submission — replaces the `:success` message |
| `send_message_on` | list | `[:success]` | Lifecycle events that message the parent: any of `[:success, :change, :submit]` |
| `data` | map | `%{}` | Initial form data for edit mode — existing record values; a payload's `data` round-trips directly |
| `form_name` | string | `"dynamic_form"` | Form namespace for params |
| `submit_text` | string | `"Submit"` | Submit button text |
| `hide_submit` | boolean | `false` | Hide the built-in submit button |
| `gettext` | atom | `DynamicForm.Gettext` | Gettext backend for translations |
| `components` | atom | `nil` | Custom components module; falls back to the `:dynamic_form, :components` config, then the built-ins per function |
| `custom_field_types` | map | `nil` | Custom field types (`%{"name" => ecto_type}`), merged over the `:dynamic_form, :custom_field_types` config |
| `validation_summary` | string | `nil` | Errors at top of form: `nil`, `"simple"`, or `"detailed"` |
| `render_only` | boolean | `false` | Render markup only: events go to the parent LiveView's `handle_event/3`; requires `form` |
| `form` | `Phoenix.HTML.Form` | `nil` | Render-only mode: the parent-owned form to render against |
| `phx_change` | string | `"validate"` | Render-only mode: change event name |
| `phx_submit` | string | `"submit"` | Render-only mode: submit event name |

Exactly one of `instance`, `json`, or `<:field>` slots must be provided.
`render_only` excludes the lifecycle attributes (`on_change`,
`change_debounce_in_ms`, `on_submit`, `on_success`, `send_message_on`,
`data`, `form_name`, `validation_summary`) and file upload questions — both
raise.

`DynamicForm.Renderer.LiveComponent` (used directly via `<.live_component>`) accepts
`id`, `instance`, and the same optional attributes from `data` down.

## `<:field>` attributes

| Attribute | Type | Applies to | Description |
|---|---|---|---|
| `type` | string | all | Required. One of the question or element types below |
| `name` | string | all | Field name. Required for question types; auto-generated for `html`/`image`/`custom` |
| `label` | any | questions, `image` | Question title / image alt text (→ `title`). Blank (`nil`, `false`, `""`) renders no label and no required marker; omitting it falls back to the capitalized name |
| `placeholder` | string | text inputs | Input placeholder |
| `description` | string | questions | Help text shown below the input |
| `input_type` | string | `text` | HTML input type pass-through (`email`, `number`, ...) |
| `default` | any | questions | Default value seeded into the form params (→ `defaultValue`) |
| `options` | list | choice types | Choices: `[{"Label", "value"}, ...]` or `["value", ...]` (→ `choices`) |
| `choices_from` | string | choice types | Carry forward: build choices from a `<:nested>` form's entries (→ `choicesFromQuestion`). Excludes `options` |
| `choice_text` | string | choice types | Label for each carried choice: a member field name, or a template — `"{min} - {max}"`, `"{panelIndex}"` (→ `choiceTextsFromQuestion`). Required with `choices_from` |
| `choice_value` | string | choice types | Value for each carried choice (→ `choiceValuesFromQuestion`; default: the entry's `dynamic_form_id`). Nested sources only |
| `choices_mode` | string | choice types | Carrying forward from another choice field: `"all"` (default), `"selected"`, `"unselected"` (→ `choicesFromQuestionMode`) |
| `no_choices_text` | string | choice types | Shown in place of the control while a carried-forward source has no entries (→ `noChoicesText`) |
| `required` | boolean | questions | Required field (→ `isRequired`). Renders the HTML `required` attribute on the control, plus a mark beside the label |
| `required_label` | any | questions | The mark beside a required label (default `"*"`); blank (`nil`, `false`, `""`) shows none while the field stays required (→ `requiredLabel`) |
| `required_if` | string | questions | Conditional requirement expression (→ `requiredIf`) |
| `visible_if` | string | all | Conditional visibility expression (→ `visibleIf`) |
| `enable_if` | string | all | Conditional enablement expression (→ `enableIf`) |
| `read_only` | boolean | questions | Display value without allowing edits (→ `readOnly`) |
| `group` | string | all | Collect this field into the `<:group>` panel with this name |
| `nested` | string | all except `file` | Data scope: collect this field into the `<:nested>` form with this name — see the [Nested Forms guide](nested-forms.md) |
| `rate_min` / `rate_max` / `rate_step` | integer | `rating` | Rating scale (defaults 1–5, step 1) |
| `min_length` / `max_length` | integer | text | Builds a `text` validator |
| `min` / `max` | number | numeric | Builds a `numeric` validator |
| `pattern` | string | text | Builds a `regex` validator |
| `format` | string | text | Format validator; supported: `"email"` |
| `validators` | list | questions | Escape hatch: `Instance.Validator` structs or atom-keyed maps |
| `html` | string | `html` | Raw HTML content (alternative to a slot body) |
| `src` | string | `image` | Image URL (→ `imageLink`); required |
| `width` / `height` / `fit` | string | `image` | Image sizing (→ `imageWidth`/`imageHeight`/`imageFit`) |
| `metadata` | map | all | Metadata map (upload config, radiogroup style, ...) |

Slot bodies: any question type accepts a body receiving its
`Phoenix.HTML.FormField` via `:let`; `html` accepts a plain body;
`custom` requires a body receiving the Phoenix form.

## `<:group>` attributes

| Attribute | Type | Description |
|---|---|---|
| `name` | string | Required. Referenced by `<:field group="...">`; also the panel's name |
| `title` | any | Panel heading. Blank (`nil`, `false`, `""`) renders none |
| `type` | string | Layout: `"horizontal"` (default, members share a row and wrap) or `"vertical"`, or a type your components module defines (→ `groupType`) |
| `visible_if` | string | Conditional visibility expression |
| `enable_if` | string | Conditional enablement (disables all contained questions when false) |
| `group` | string | Place this group inside another `<:group>` panel; it must declare the same `nested` scope as that parent |
| `nested` | string | Data scope this group lives in; every member field must declare the identical scope |

## `<:nested>` attributes

Declares a repeating child form (→ a SurveyJS `paneldynamic` question);
fields join it with `<:field nested="...">`. See the
[Nested Forms guide](nested-forms.md).

| Attribute | Type | Description |
|---|---|---|
| `name` | string | Required. Data key — the value is a list of entry maps |
| `title` / `description` | any / string | Section heading and help text, rendered above the entries with the add button opposite them. Ordinary values, so `title={gettext("Age groups")}` works; a blank title (`nil`, `false`, `""`) renders no heading |
| `entry_title` | any | Per-entry heading; `{panelIndex}` interpolates the 1-based number (→ `templateTitle`). Blank renders none |
| `entries` | integer | Entries seeded on a fresh form (→ `panelCount`) |
| `min_entries` / `max_entries` | integer | Count limits: buttons hide, and submit validates (→ `minPanelCount`/`maxPanelCount`) |
| `add_text` / `remove_text` | string | Button labels (→ `addPanelText`/`removePanelText`). Remove is an icon button, so its label becomes the tooltip and screen-reader name |
| `no_entries_text` | string | Shown at zero entries (→ `noEntriesText`) |
| `confirm_delete` / `confirm_text` | boolean / string | Confirmation before removing (→ `confirmDelete`/`confirmDeleteText`) |
| `key` / `key_error` | string | Member field unique across entries + error message (→ `keyName`/`keyDuplicationError`) |
| `generate_ids` | boolean | Seed each entry with a stable `dynamic_form_id` (default `true`; → `generateIds`) |
| `default` | list | Initial value: list of entry maps (→ `defaultValue`) |
| `default_entry` | map | Values seeded into each newly added entry (→ `defaultPanelValue`) |
| `required` | boolean | At least one entry required (→ `isRequired`) |
| `required_label` | any | Mark beside the section heading when required (default `"*"`); blank shows none (→ `requiredLabel`) |
| `visible_if` / `enable_if` | string | Conditional expressions |
| `nested` | string | Place this nested form inside another `<:nested>` form |
| `group` | string | Place this nested form inside a `<:group>` panel |

## Question types

| Type | Renders as | Notes |
|---|---|---|
| `text` | `<input>` | `input_type` passes through (`email`, `number`, ...); `number` casts to decimal |
| `comment` | `<textarea>` | |
| `dropdown` | `<select>` | Requires `options` |
| `radiogroup` | Radio buttons | Requires `options`; `metadata` `"style"`: `"vertical"`/`"horizontal"` |
| `checkbox` | Checkbox group | Array-valued; requires `options` |
| `tagbox` | Multi-select | Array-valued; requires `options` |
| `boolean` | Single checkbox | |
| `rating` | Numeric radio row | `rate_min`/`rate_max`/`rate_step`; casts to integer |
| `file` | Direct upload | Presigner + uploader required — see [Usage: File uploads](usage.md#file-uploads) |
| `paneldynamic` | Repeating child form | Casts to a list of maps, validated per entry; `<:nested>` in declarative mode — see [Nested Forms](nested-forms.md) |

## Element types

| Type | Renders as | Notes |
|---|---|---|
| `html` | Raw HTML or slot body | String attr goes through `Phoenix.HTML.raw/1`; slot bodies are escaped HEEx |
| `panel` | Titled container | Declared via `<:group>` in declarative mode; nestable in both modes. `groupType` picks the layout: `"horizontal"` (default) or `"vertical"` |
| `image` | `<img>` | `src` required |
| `custom` | Slot body | Declarative-only; body receives the Phoenix form |

## Validators

Data-mode JSON validator objects (built automatically by the flattened attrs
in declarative mode). Each accepts a custom error message via `text`:

| Type | Fields | Flattened attrs |
|---|---|---|
| `text` | `minLength`, `maxLength` | `min_length`, `max_length` |
| `numeric` | `minValue`, `maxValue` | `min`, `max` |
| `email` | — | `format="email"` |
| `regex` | `regex` | `pattern` |

`input_type="email"` also applies email format validation automatically.

## Conditional expression operators

Used by `visible_if`, `required_if`, and `enable_if`:

| Category | Operators |
|---|---|
| Comparison | `=`, `==`, `<>`, `!=`, `>`, `<`, `>=`, `<=` |
| Presence | `empty`, `notempty` |
| Membership | `contains`, `notcontains`, `anyof`, `allof`, `noneof` |
| Combinators | `and`, `or`, parentheses |

Field references use braces: `{field_name}`. Literals: `'strings'`, numbers,
`true`/`false`, `['lists', 'of', 'values']`.

## Messages

Sent to the parent LiveView, carrying the lifecycle event and the payload:

```elixir
{:dynamic_form, event, %DynamicForm.Payload{}}
```

| Event | Sent | In `send_message_on` by default? |
|---|---|---|
| `:success` | On a valid submission (replaced by `on_success` when defined) | yes |
| `:change` | On every change, after the built-in validations and `on_change` | no |
| `:submit` | On every submit — valid or not — after `on_submit` | no |

A valid submission with all three enabled delivers `:change`, `:submit`, and
`:success`, in that order. `:change` and `:submit` payloads are routinely
invalid — check `DynamicForm.Payload.valid?/1`.

`%DynamicForm.Payload{}` fields:

| Field | Value |
|---|---|
| `id` | The form component's id |
| `changeset` | The form's `Ecto.Changeset`; its `valid?` flag is the source of truth for validity (always `true` for `:success`) |
| `data` | The applied changeset data |
| `extra` | Empty map by default; written by `on_submit` via `Payload.put_extra/3` |

## Lifecycle callback contracts

```elixir
on_change:  (DynamicForm.Payload.t()) -> DynamicForm.Payload.t()
on_submit:  (DynamicForm.Payload.t()) -> DynamicForm.Payload.t()
on_success: (DynamicForm.Payload.t()) -> any()
```

`on_change` runs after built-in validations, on every change (including a
nested entry add/remove) and during the submit validation pass — after
`change_debounce_in_ms` milliseconds of quiet when that attribute is set,
except on submit, where it always runs inline. `on_submit` runs on every
submit — valid or not — so it can batch expensive checks with the built-in
errors into one complete error list. Both are validation hooks that run
*alongside* the built-in behavior: reject a submission with
`DynamicForm.Payload.add_error/4` (validity lives on the changeset, so
adding an error marks the submission invalid); perform side effects in the
parent's `handle_info/2` instead.

`on_success` runs on every valid submission and *replaces* the `:success`
message; its return value is ignored. Listing `:success` in
`send_message_on` alongside it raises.

Messages reach the LiveView process only. A LiveComponent parent uses the
callbacks with `Phoenix.LiveView.send_update/2` instead — see the
[Lifecycle guide](lifecycle.md#example-using-callbacks-to-send-updates-to-a-livecomponent-instead-of-liveview).

## Upload metadata keys

`metadata` map keys for `type="file"` questions:

| Key | Default | Description |
|---|---|---|
| `"max_entries"` | `3` | Maximum number of files |
| `"max_file_size"` | `10_000_000` | Maximum file size in bytes |
| `"accept"` | `:any` | Accepted extensions/MIME types |
| `"bucket"` | — | Cloud storage bucket |
| `"object_name_prefix"` | `""` | Prefix for stored object names |
| `"presigner"` | — | `%{"module" => ..., "function" => ...}` returning a presigned URL |

Uploaded files are stored in the form data as maps with `filename`,
`cloud_bucket`, `cloud_path`, `cloud_provider`, and `uploaded_on`.

## Custom field types

Registered as `%{"type_name" => ecto_type}` via the
`:dynamic_form, :custom_field_types` config and/or the `custom_field_types`
attribute (per-form entries win). The Ecto type drives casting (`{:array, _}`
types get checkbox-group-style param normalization); rendering dispatches to
the components module's `input/1`, matched by a
`def input(%{type: "type_name"} = assigns)` clause. Names colliding with
built-in types raise. Unregistered question types render nothing.

## Components contract

Functions the renderer dispatches through the `components` module
(per-function fallback to `DynamicForm.CoreComponents`):

| Function | Renders | In Phoenix-generated CoreComponents? |
|---|---|---|
| `input/1` | text/email/number, textarea, select, checkbox controls | yes — works out of the box |
| `input_radio_group/1` | radiogroup and rating questions | no — built-in fallback |
| `input_checkbox_group/1` | multi-select checkbox groups | no — built-in fallback |
| `label/1`, `error/1` | around custom-control slot bodies | no — built-in fallback |
| `dynamic_form_group/1` | groups (panels) | no — built-in fallback |
| `nested_entry/1` | the container around each repeating nested-form entry | no — built-in fallback |
| `button/1` | the submit button | yes — delegates |
| `translate_error/1` | error messages via the app's Gettext | yes — delegates |

## Helper functions

| Function | Description |
|---|---|
| `DynamicForm.Parser.JSON.parse!/1` | JSON string or map → `Instance` struct |
| `DynamicForm.Instance.strip_slots/1` | Copy of an instance without slot bodies (definition-only comparison) |
| `DynamicForm.Parser.Declarative.parse!/1` | Slot entries → `Instance` (used by `DynamicForm.form/1`) |
| `DynamicForm.Changeset.create_changeset/2` | Instance + params → Ecto changeset |
| `DynamicForm.Changeset.list_questions/1` | Flat list of questions, including nested panels |
| `DynamicForm.Payload.add_error/4` | Add a changeset error, marking the submission invalid |
| `DynamicForm.Payload.put_extra/3` | Stash derived data on the payload for the parent's `handle_info/2` |
| `DynamicForm.form_data/1` | Inside a `<:field>` slot body: the whole form's current values, same shape as `payload.data` |
| `DynamicForm.submit_button/1` | Submit button component usable outside the form element |
