react-luna-form 0.0.83 → 0.0.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,129 @@
1
+ # Submitting
2
+
3
+ What happens between the user pressing the button and your code receiving an
4
+ object. This page is about the client `Form` — the server one renders fields and
5
+ has no submit path of its own.
6
+
7
+ ## Nothing submits without a control
8
+
9
+ The library renders no button. Whatever you pass as `children` becomes the
10
+ form's control area:
11
+
12
+ ```tsx
13
+ <Form sections={sections} config={config} action={save}>
14
+ {({ isPending }) => (
15
+ <button type="submit" disabled={isPending}>
16
+ {isPending ? 'Saving…' : 'Save'}
17
+ </button>
18
+ )}
19
+ </Form>
20
+ ```
21
+
22
+ A plain node works as well; the function form is what gets `isPending`, which is
23
+ true from the moment the action starts until it settles.
24
+
25
+ ## The pipeline
26
+
27
+ On submit, in this order:
28
+
29
+ 1. **The form data becomes an object.** A name that appears more than once
30
+ arrives as an array; everything else as a single value.
31
+ 2. **Date fields are normalized.** A date input holds its value in the display
32
+ format the field declares, and it is converted back to a native date before
33
+ validation, so the schema and your action see one shape whatever
34
+ `advanced.format` says.
35
+ 3. **It is validated** against the schema built from the fields that are
36
+ currently mounted.
37
+ 4. **Dotted names are un-flattened.** `basicAuth.username` becomes
38
+ `{ basicAuth: { username } }`, and a numeric segment becomes an array index,
39
+ so `rules.0.value` rebuilds the list — with the gaps of removed rows closed
40
+ up.
41
+ 5. **Your `action` is awaited** with that object and the schema.
42
+ 6. **`onSuccess` is called** with what the action returned, and the form clears
43
+ itself.
44
+
45
+ ## Only mounted fields are validated
46
+
47
+ The client schema is assembled from the fields on screen. A field hidden by a
48
+ [`state` action](../events/change.md#2-state-action-stateevent) is unmounted, so
49
+ it is absent from the schema and from the submitted object — which is the point:
50
+ a rule that does not apply cannot block a submit.
51
+
52
+ The consequence is that the browser check is narrower than the
53
+ [headless one](../validation/overview.md#headless-validation).
54
+ `buildFormSchema` walks the whole tree, item-scoped rules and list lengths
55
+ included. A server that re-validates with it is not repeating the client's work;
56
+ it is doing the part the client structurally cannot.
57
+
58
+ ## What `action` receives and returns
59
+
60
+ ```ts
61
+ type FormState<T> = {
62
+ data: T | null
63
+ error: {
64
+ title: string
65
+ description?: string
66
+ details?: string[] | Record<string, string[]>
67
+ } | null
68
+ success: boolean
69
+ }
70
+
71
+ async function save(
72
+ data: Payload,
73
+ schema?: ZodSchema
74
+ ): Promise<FormState<Saved>> {
75
+ const stored = await api.save(data)
76
+ return { data: stored, error: null, success: true }
77
+ }
78
+ ```
79
+
80
+ The schema is handed over with the data so a server action can re-parse the same
81
+ rules rather than reconstructing them.
82
+
83
+ Three ways it can end:
84
+
85
+ - **`success: true`** — `onSuccess` receives `data`, and the form clears.
86
+ - **`success: false`** — the `error` you return is rendered by the form's
87
+ [alert](../setup.md#style-alert-env), untouched. This is where a server-side
88
+ failure ("that email is already registered") belongs.
89
+ - **It throws** — the error is caught and logged, and the alert shows a generic
90
+ title with the thrown message as its detail. A rejection is a bug report, not
91
+ a message for the user; return `success: false` for anything you want them to
92
+ read.
93
+
94
+ ## The form empties on a successful submit
95
+
96
+ Once the action succeeds the values are cleared and the inputs come back blank.
97
+ For the usual "submit and move on" this is what you want, and for a form that
98
+ should keep what was submitted it is a surprise.
99
+
100
+ If the values have to survive, hold them outside the form: mirror
101
+ `onValueChange` into your own state and feed it back through `value` — see
102
+ [`withDeclaredFields`](../setup.md#withdeclaredfields) for the part that trips
103
+ people, which is that a key you stop sending is not the same as a key you send
104
+ empty.
105
+
106
+ A **failed** validation is the opposite: the submitted values are handed back to
107
+ the form, so the user's typing is still on screen next to the errors.
108
+
109
+ ## Turning submit validation off
110
+
111
+ `defineConfig({ validation: { submit: false, ... } })` skips the parse
112
+ entirely. The action is called with the raw `FormData` and the schema, and
113
+ nothing is checked, reported or cleared on the way — for a form whose action
114
+ does its own parsing. With no action at all, submitting does nothing.
115
+
116
+ Remember that this object [replaces the defaults rather than merging](../setup.md#validation),
117
+ so pass the keys you still want.
118
+
119
+ ## The error summary
120
+
121
+ When a validation fails, two things happen at once: each field is told about its
122
+ own errors, and the form reports a summary. The summary renders only when
123
+ `validation.showError` is on and a component is registered as `alert`; without
124
+ one, the per-field messages are all the user sees.
125
+
126
+ Its three strings — the title, the "correct the errors" description, and the
127
+ title used when an action throws — are
128
+ [shipped translations](../fields/list.md#built-in-translations), so `lang` alone
129
+ localizes them.
package/docs/index.md CHANGED
@@ -9,21 +9,27 @@ field descriptors is the single source for what renders **and** for the Zod
9
9
  schema that validates it. Most mistakes come from assuming otherwise.
10
10
 
11
11
  Start at [setup.md](setup.md) if you are wiring the form up for the first
12
- time: entry points, `defineConfig`, and the difference between the client and
13
- server `Form`.
12
+ time: installing, styling, entry points, `defineConfig`, and the difference
13
+ between the client and server `Form`.
14
14
 
15
15
  ## Where to look
16
16
 
17
17
  | You are | Read |
18
18
  | ---------------------------------------------------------------------------- | ------------------------------------------------------------------ |
19
+ | Grouping fields into a titled, collapsible or numbered section | [structure/sections.md](structure/sections.md) |
20
+ | Putting fields side by side, or a label beside its control | [structure/layout.md](structure/layout.md) |
21
+ | Reusing one piece of JSON across the form | [structure/definition.md](structure/definition.md) |
19
22
  | Declaring a text, number, date or password field | [fields/input.md](fields/input.md) |
20
23
  | Declaring a `select`, `radio` or `chips` field, or filling one from a source | [fields/select.md](fields/select.md) |
21
24
  | Declaring a repeatable group of fields | [fields/list.md](fields/list.md) |
22
25
  | Looking for a date range, a color or another ready-made picker | [fields/specialized-selectors.md](fields/specialized-selectors.md) |
23
26
  | Implementing a field type this library does not ship | [fields/custom-inputs.md](fields/custom-inputs.md) |
27
+ | Filling a `select` from an API | [fields/data-source.md](fields/data-source.md) |
24
28
  | Making a field required, or validating against another field | [validation/overview.md](validation/overview.md) |
29
+ | Handling the submit, a server action or the error summary | [forms/submit.md](forms/submit.md) |
25
30
  | Showing, hiding, clearing or filling a field when another one changes | [events/change.md](events/change.md) |
26
- | Formatting an interpolated value inside a label or a message | [interpolation/format-filters.md](interpolation/format-filters.md) |
31
+ | Writing a `{placeholder}` in a label, a description or an event | [interpolation/overview.md](interpolation/overview.md) |
32
+ | Formatting an interpolated value as currency, a date or a duration | [interpolation/format-filters.md](interpolation/format-filters.md) |
27
33
 
28
34
  ## Three things that surprise people
29
35
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  Format filters transform interpolated values into locale-aware strings using a pipe syntax inspired by Vue and Angular.
4
4
 
5
+ This page is the filter reference. What a placeholder resolves against in the first place — and it is not the same set everywhere — is [Interpolation](overview.md).
6
+
5
7
  ## Where filters can be used
6
8
 
7
9
  Filters work in any string passed through Luna Form's interpolation engine, including:
@@ -27,6 +29,8 @@ Multiple filters can be chained; the output of the previous filter becomes the i
27
29
 
28
30
  Locale-aware filters read the locale from `config.env.locale`. If not set, filters fall back to the runtime default (typically the browser's locale).
29
31
 
32
+ It applies wherever a filter is written — a label, a description, a `value` payload — so one setting keeps them consistent. See [Interpolation](overview.md#the-locale-filters-use).
33
+
30
34
  ```ts
31
35
  const config = {
32
36
  env: {
@@ -0,0 +1,179 @@
1
+ # Interpolation
2
+
3
+ A `{placeholder}` in a form is resolved against a set of values that depends on
4
+ **where the placeholder is written**. There are two of them, and most confusion
5
+ about interpolation is one being read as the other.
6
+
7
+ - **In rendered text** — a field's `label`, a field or column `description` —
8
+ the values are `context`, `env` and, in a description, that field's own
9
+ `value`.
10
+ - **In an event payload** — what a [`value` action](../events/change.md#1-value-action-valueevent)
11
+ writes, and the `url` and `body` of a [`source` action](../events/change.md#3-source-action-sourceevent) —
12
+ the values are **the selected option itself**: `{value}`, `{label}` and every
13
+ other key the option carries.
14
+
15
+ [Format filters](format-filters.md) work in both.
16
+
17
+ ## Where each namespace applies
18
+
19
+ | Written in | Resolves against |
20
+ | --------------------------------- | --------------------------------------- |
21
+ | a field `label` | `context`, `env` |
22
+ | a field `description` | `context`, `env`, `value` |
23
+ | a column `description` | `context`, `env` |
24
+ | a `state` action's `description` | `context`, `env`, `value` of the target |
25
+ | a `value` action's payload | the selected option |
26
+ | a `source` action's `url`, `body` | the selected option |
27
+ | a section `title`, `description` | nothing — see below |
28
+
29
+ ## Rendered text
30
+
31
+ `context` is a prop of the form and `env` is a key of
32
+ [`defineConfig`](../setup.md#style-alert-env). Both are addressed by name, with
33
+ dot notation for anything nested:
34
+
35
+ ```tsx
36
+ <Form
37
+ sections={sections}
38
+ config={config}
39
+ context={{ user: { firstName: 'Jane' }, plan: 'pro' }}
40
+ />
41
+ ```
42
+
43
+ ```json
44
+ {
45
+ "name": "greeting",
46
+ "type": "input/text",
47
+ "label": "Welcome {context.user.firstName}",
48
+ "description": "You are on the {context.plan} plan, billed in {env.currency}"
49
+ }
50
+ ```
51
+
52
+ Three details decide most of what you will hit:
53
+
54
+ - **`{value}` is a description's, not a label's.** A description is rendered
55
+ with the field's current value in scope, so `{value}` there tracks what the
56
+ user typed. A label is not: the same placeholder in a `label` resolves to
57
+ nothing and stays on screen as the literal `{value}`.
58
+ - **Interpolation runs first, translation second.** The resolved string is what
59
+ gets looked up in the [`translations`](../fields/list.md#localization)
60
+ dictionary, so a label written as `Welcome {context.user.firstName}` is
61
+ looked up as `Welcome Jane` — which is not a key anybody wrote. Translate the
62
+ whole sentence with the placeholder inside it (`"welcome_user": "Hola
63
+ {context.user.firstName}"` keyed on the source text) rather than expecting the
64
+ key to survive.
65
+ - **A section heading is not interpolated at all.** `title` and `description` on
66
+ a [section](../structure/sections.md#title-and-description-are-translated-not-interpolated)
67
+ are translated and take markdown links, but a placeholder in one is printed
68
+ as written. A column description is interpolated, so that is the place to put
69
+ one.
70
+
71
+ ## Event payloads
72
+
73
+ What a change event carries is the **selected option**, not the form's values.
74
+ For a field whose options are declared or fetched, that is the whole row behind
75
+ the selection:
76
+
77
+ ```json
78
+ {
79
+ "name": "country",
80
+ "type": "select",
81
+ "source": [
82
+ { "value": "cl", "label": "Chile", "dial": "+56" },
83
+ { "value": "mx", "label": "Mexico", "dial": "+52" }
84
+ ],
85
+ "event": {
86
+ "change": [
87
+ {
88
+ "action": "value",
89
+ "value": { "phone_prefix": "{dial}", "country_name": "{label}" }
90
+ }
91
+ ]
92
+ }
93
+ }
94
+ ```
95
+
96
+ Selecting Chile writes `+56` and `Chile`. The keys available are the keys of
97
+ that option: there is no `{context.*}` or `{env.*}` here, and no way to read
98
+ another field's value.
99
+
100
+ Two shapes to keep in mind:
101
+
102
+ - **A text, textarea or date field carries `{ value }` and nothing else.** There
103
+ is no option row behind it, so `{value}` is the only placeholder that resolves
104
+ in its events.
105
+ - **A `chips` field carries its array** — see
106
+ [change.md](../events/change.md#when-a-change-event-runs-at-mount). `{value}`
107
+ is the array itself, and there is no `{label}` to read.
108
+
109
+ When the option rows do not use `value` as their identifying key — a remote
110
+ source mapped with `advanced.options` — the row is found through
111
+ [`advanced.entity`](../fields/select.md#advancedentity). Without it the event
112
+ sees only `{ value }` and every other placeholder stays literal.
113
+
114
+ ### A `state` description is interpolated by the field that shows it
115
+
116
+ Unlike `value` and `source`, a [`state` action](../events/change.md#2-state-action-stateevent)
117
+ stores its `description` as written and the **target** interpolates it when it
118
+ renders. So `{value}` in that description is the target field's own value, not
119
+ the value of the field whose change set it.
120
+
121
+ Aimed at the field that raised the event — the common case — the two are the
122
+ same thing, which is why this rarely comes up:
123
+
124
+ ```json
125
+ {
126
+ "action": "state",
127
+ "target": "price",
128
+ "state": { "description": "Total to pay: {value | currency:USD}" }
129
+ }
130
+ ```
131
+
132
+ Aimed at a different field, `{value}` reads that other field, and it is empty
133
+ until somebody fills it.
134
+
135
+ ## Resolution rules
136
+
137
+ - **Dot notation walks objects.** `{context.user.firstName}`, `{env.branding.name}`.
138
+ - **A key that resolves to nothing is left alone.** The placeholder stays in the
139
+ text exactly as written — `{context.missing}` renders as `{context.missing}`.
140
+ This is deliberate: a half-rendered sentence is easier to spot than a blank.
141
+ - **So is a value that is not a scalar.** A placeholder standing for an object or
142
+ an array is left literal in text. The one exception is a `value` action, where
143
+ a template that is _nothing but_ the placeholder hands the structure over
144
+ untouched — that is what fills a
145
+ [list](../events/change.md#targeting-a-list).
146
+ - **An unknown filter leaves the whole placeholder.** `{total | nope}` renders as
147
+ `{total | nope}` rather than dropping the value.
148
+ - **Spaces are trimmed.** `{ value }` and `{value}` are the same placeholder, as
149
+ are `{total|currency:USD}` and `{total | currency:USD}`.
150
+
151
+ ## The locale filters use
152
+
153
+ Locale-aware filters read `config.env.locale`:
154
+
155
+ ```ts
156
+ defineConfig({ inputs: [...], env: { locale: 'es-ES', currency: 'EUR' } })
157
+ ```
158
+
159
+ It reaches every filter the form renders — a label and a description format the
160
+ same value the same way. With no `locale` set, filters fall back to the locale of
161
+ whatever is running the code: the browser in the client form, the process in the
162
+ server one, which are not always the same machine. Set it once in `env` and both
163
+ renders agree.
164
+
165
+ ## An unresolved placeholder cancels a request
166
+
167
+ A `source.url` still holding a `{placeholder}` after interpolation is **not
168
+ fetched**. The field renders with no options, no request appears in the network
169
+ panel, and nothing is logged: from the outside it looks exactly like an empty
170
+ response.
171
+
172
+ That is the intended behaviour — a cascading select should not call
173
+ `/api/states?country={value}` before a country is picked — but it is also what a
174
+ typo produces. A field that never fetches is worth checking for a placeholder
175
+ that nothing resolves, before checking the network.
176
+
177
+ A URL that _is_ fully resolved and still comes back empty is a different
178
+ problem: see [`fetcher.remotePatterns`](../setup.md#fetcherremotepatterns),
179
+ which does log when it blocks one.
package/docs/setup.md CHANGED
@@ -4,6 +4,55 @@ How a form is configured and rendered. The pages under `fields/`,
4
4
  `validation/` and `events/` describe the JSON; this one describes the code
5
5
  around it.
6
6
 
7
+ ## Installing
8
+
9
+ ```bash
10
+ pnpm add react-luna-form
11
+ ```
12
+
13
+ The package carries no dependencies of its own: everything it needs is a peer
14
+ dependency, so your application resolves one copy of React, Zod and the rest.
15
+
16
+ | Peer | Version | Used for |
17
+ | ----------------------- | ------------------- | ------------------------------------------- |
18
+ | `react`, `react-dom` | `^19.0.0` | rendering, and `useActionState` on submit |
19
+ | `zod` | `^4.0.0` | the schema derived from `sections` |
20
+ | `jotai`, `jotai-family` | `^2.0.0` / `^1.0.0` | the value, error and source stores |
21
+ | `swr` | `^2.0.0` | fetching a field's remote `source` |
22
+ | `date-fns` | `^4.0.0` | date and time formatting, `duration` filter |
23
+ | `fast-equals` | `^6.0.0` | change detection in the stores |
24
+ | `tailwind-merge` | `^3.5.0` | merging the classes the components render |
25
+
26
+ None of them is optional, and none is bundled. A package manager that installs
27
+ peers automatically (npm 7+, or pnpm with `auto-install-peers=true`) brings
28
+ them in for you; anywhere else, install them alongside the library. A missing
29
+ peer surfaces as a module resolution error at import time, not as a form that
30
+ renders badly.
31
+
32
+ ## Styling
33
+
34
+ The components render Tailwind utility classes and **the build ships no CSS**.
35
+ With [Tailwind CSS v4](https://tailwindcss.com) installed, point it at the
36
+ package so the classes inside the published bundle are generated:
37
+
38
+ ```css
39
+ @import 'tailwindcss';
40
+
41
+ @source '../node_modules/react-luna-form';
42
+ ```
43
+
44
+ Without that `@source` line every class the library renders is scanned out as
45
+ unused: the markup is correct, the labels and inputs are all there, and none of
46
+ the layout is — which reads as a broken build rather than a missing line of
47
+ CSS.
48
+
49
+ Every element also carries a `data-slot` attribute — `field`, `field-label`,
50
+ `field-control`, `field-set`, `field-set-content`, `field-content`,
51
+ `field-group`, `field-separator`, `column`, `list-item-card` — so a design
52
+ system can restyle the structure without patching the library. The components
53
+ you register yourself are yours to style; these slots cover the scaffolding
54
+ around them.
55
+
7
56
  ## Entry points
8
57
 
9
58
  | Import | Contains |
@@ -57,7 +106,7 @@ Pass every key you still want.
57
106
 
58
107
  ### `fetcher.remotePatterns`
59
108
 
60
- Which hosts a field's remote `source` may call. The rules:
109
+ Which hosts a field's remote `source` may call — the source itself is documented in [fields/data-source.md](fields/data-source.md). The rules:
61
110
 
62
111
  | Value | Effect |
63
112
  | ------- | ------------------------------------ |
@@ -95,19 +144,21 @@ Check the console when a remote source comes back empty.
95
144
  The client form. It owns its own state, so it is the one to use for anything
96
145
  interactive.
97
146
 
98
- | Prop | Purpose |
99
- | ----------------------------------------- | ---------------------------------------------------------------------- |
100
- | `sections` | the field descriptors — the form itself |
101
- | `config` | the result of `defineConfig` |
102
- | `value` | the current values |
103
- | `action` | server action invoked on submit, receives the form data and the schema |
104
- | `onSuccess` | called with the parsed data after `action` succeeds |
105
- | `onValueChange` | called with `{ name, value }` on every change |
106
- | `readOnly` | renders every field disabled |
107
- | `translations` | per-locale strings for labels, options and messages |
108
- | `lang` | the locale to resolve `translations` against |
109
- | `context` | arbitrary values available to interpolation |
110
- | `definition`, `advanced.step`, `children` | multi-step and custom controls |
147
+ | Prop | Purpose |
148
+ | --------------- | --------------------------------------------------------------------------------------------------------------- |
149
+ | `sections` | the field descriptors — the form itself |
150
+ | `config` | the result of `defineConfig` |
151
+ | `value` | the current values |
152
+ | `action` | server action invoked on submit, receives the form data and the schema — see [forms/submit.md](forms/submit.md) |
153
+ | `onSuccess` | called with the parsed data after `action` succeeds |
154
+ | `onValueChange` | called with `{ name, value }` on every change |
155
+ | `readOnly` | renders every field disabled |
156
+ | `translations` | per-locale strings for labels, options and messages |
157
+ | `lang` | the locale to resolve `translations` against |
158
+ | `context` | arbitrary values available to interpolation |
159
+ | `definition` | the JSON that [`$ref`](structure/definition.md) entries resolve against |
160
+ | `advanced.step` | numbers the [sections](structure/sections.md#advancedstep-numbers-the-sections) |
161
+ | `children` | the form's control area — see [forms/submit.md](forms/submit.md#nothing-submits-without-a-control) |
111
162
 
112
163
  ### `Form` from `react-luna-form/server`
113
164
 
@@ -0,0 +1,99 @@
1
+ # Definition and `$ref`
2
+
3
+ A definition names a piece of JSON once so the rest of the form can point at it.
4
+ Anywhere inside `sections`, an object shaped `{ "$ref": "#/definition/<path>" }`
5
+ is replaced by whatever `<path>` holds in the `definition` you pass alongside
6
+ them.
7
+
8
+ ```tsx
9
+ <Form sections={sections} definition={definition} config={config} />
10
+ ```
11
+
12
+ ```json
13
+ {
14
+ "definition": {
15
+ "sources": {
16
+ "countries": { "url": "/api/countries", "namespace": "results" }
17
+ }
18
+ },
19
+ "sections": [
20
+ {
21
+ "fields": [
22
+ {
23
+ "name": "origin",
24
+ "type": "select",
25
+ "label": "Origin",
26
+ "source": { "$ref": "#/definition/sources.countries" }
27
+ },
28
+ {
29
+ "name": "destination",
30
+ "type": "select",
31
+ "label": "Destination",
32
+ "source": { "$ref": "#/definition/sources.countries" }
33
+ }
34
+ ]
35
+ }
36
+ ]
37
+ }
38
+ ```
39
+
40
+ ## The path is dotted, not slashed
41
+
42
+ Everything after `#/definition/` is a **dot path** into the definition object:
43
+ `#/definition/sources.countries`, never `#/definition/sources/countries`. The
44
+ prefix is the only part that uses slashes, and it looks enough like a JSON
45
+ Pointer to be written the wrong way on the first try.
46
+
47
+ ## What can be a reference
48
+
49
+ Any object in the tree. The `$ref` object is replaced whole, so a reference can
50
+ stand for a `source`, an array of options, a whole field, or the `fields` of a
51
+ section:
52
+
53
+ ```json
54
+ {
55
+ "definition": {
56
+ "email": {
57
+ "name": "email",
58
+ "type": "input/email",
59
+ "label": "Email",
60
+ "required": true
61
+ }
62
+ },
63
+ "sections": [{ "fields": [{ "$ref": "#/definition/email" }] }]
64
+ }
65
+ ```
66
+
67
+ References inside a definition are resolved too, so one entry can be built out
68
+ of others.
69
+
70
+ ## The rules
71
+
72
+ - **Resolution happens once, before anything else.** The tree is resolved first,
73
+ and only then filtered and sorted, so an `order` or a `hidden` that arrives
74
+ through a reference behaves like one written in place.
75
+ - **An unresolved path leaves the reference standing.** Nothing throws: the node
76
+ stays as the literal `{ "$ref": ... }` object. What that looks like on screen
77
+ depends on where it was — a field whose `source` is still a reference renders
78
+ with no options at all, which is the usual way a typo in the path is noticed.
79
+ - **An empty or missing `definition` resolves nothing.** `definition={{}}` is
80
+ treated as absent, and every `$ref` in the form is left as written.
81
+ - **A cycle stops instead of hanging.** Two entries pointing at each other
82
+ resolve until the loop closes and leave the reference that would repeat
83
+ unresolved.
84
+
85
+ ## Pass it everywhere the sections go
86
+
87
+ `definition` is a prop of both the client and the server `Form`, and the third
88
+ argument of the headless builder:
89
+
90
+ ```ts
91
+ import { buildFormSchema } from 'react-luna-form/schema'
92
+
93
+ const schema = buildFormSchema(sections, translations, definition)
94
+ ```
95
+
96
+ A schema built without the definition sees `$ref` objects where the form sees
97
+ fields — the rendered form and the validated shape drift apart, which is the one
98
+ thing [the schema being derived from the same descriptors](../validation/overview.md#headless-validation)
99
+ is meant to prevent.