react-luna-form 0.0.83 → 0.0.84
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.
- package/README.md +140 -0
- package/dist/client/cjs/index.js +1 -1
- package/dist/client/esm/index.js +1 -1
- package/dist/server/cjs/index.js +1 -1
- package/dist/server/esm/index.js +1 -1
- package/dist/types/luna-core/util/build.d.ts +1 -1
- package/dist/types/luna-core/util/schema.d.ts +4 -4
- package/dist/types/luna-core/util/style.d.ts +1 -5
- package/docs/events/change.md +45 -2
- package/docs/fields/data-source.md +113 -0
- package/docs/fields/input.md +1 -1
- package/docs/fields/list.md +5 -2
- package/docs/fields/select.md +49 -1
- package/docs/forms/submit.md +129 -0
- package/docs/index.md +9 -3
- package/docs/interpolation/format-filters.md +4 -0
- package/docs/interpolation/overview.md +179 -0
- package/docs/setup.md +65 -14
- package/docs/structure/definition.md +99 -0
- package/docs/structure/layout.md +189 -0
- package/docs/structure/sections.md +187 -0
- package/docs/validation/overview.md +1 -1
- package/package.json +4 -4
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
|
|
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
|
-
|
|
|
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
|
|
99
|
-
|
|
|
100
|
-
| `sections`
|
|
101
|
-
| `config`
|
|
102
|
-
| `value`
|
|
103
|
-
| `action`
|
|
104
|
-
| `onSuccess`
|
|
105
|
-
| `onValueChange`
|
|
106
|
-
| `readOnly`
|
|
107
|
-
| `translations`
|
|
108
|
-
| `lang`
|
|
109
|
-
| `context`
|
|
110
|
-
| `definition
|
|
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.
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# Layout
|
|
2
|
+
|
|
3
|
+
Inside a section, fields stack one per row. Everything else — fields side by
|
|
4
|
+
side, a label beside its control — is opt in, and this page is where those keys
|
|
5
|
+
live.
|
|
6
|
+
|
|
7
|
+
## Columns (`type: "column"`)
|
|
8
|
+
|
|
9
|
+
A `column` is a grid that holds fields. It is declared among the fields of a
|
|
10
|
+
section (or of a [list](../fields/list.md) row) and takes a `fields` array of
|
|
11
|
+
its own:
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"type": "column",
|
|
16
|
+
"advanced": { "cols": 2 },
|
|
17
|
+
"fields": [
|
|
18
|
+
{ "name": "first_name", "type": "input/text", "label": "First name" },
|
|
19
|
+
{ "name": "last_name", "type": "input/text", "label": "Last name" }
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
A column has no `name` and holds no value. It groups its fields visually and
|
|
25
|
+
nothing more: they keep the names they declare, and the submitted object is the
|
|
26
|
+
same one you would get without it.
|
|
27
|
+
|
|
28
|
+
| Key | Type | Does |
|
|
29
|
+
| --------------- | ---------------------- | ------------------------------------------ |
|
|
30
|
+
| `type` | `"column"` | required, exactly this string |
|
|
31
|
+
| `fields` | `Array<Field \| List>` | what the grid holds |
|
|
32
|
+
| `advanced.cols` | `1 \| 2 \| 3` | how many columns the grid has, default `2` |
|
|
33
|
+
| `description` | `string \| object` | help text under the grid |
|
|
34
|
+
| `order` | `number` | position among the section's other entries |
|
|
35
|
+
|
|
36
|
+
### `cols`, and the values that are not
|
|
37
|
+
|
|
38
|
+
`cols` accepts `1`, `2` and `3`. **Anything else falls back to `2`** —
|
|
39
|
+
`"cols": 4`, `"cols": 0`, a string, or the key left out entirely all produce the
|
|
40
|
+
same two-column grid, with no error and no warning. A four-column layout is not
|
|
41
|
+
something to reach for by writing `4`; it does not exist.
|
|
42
|
+
|
|
43
|
+
The grid is one column below the `md` breakpoint and `cols` columns from there
|
|
44
|
+
up, so a column layout stacks on a phone whatever it says.
|
|
45
|
+
|
|
46
|
+
### A field's own `cols` is a span
|
|
47
|
+
|
|
48
|
+
Inside a column, a field's `advanced.cols` says how many of the grid's columns
|
|
49
|
+
that field takes:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"type": "column",
|
|
54
|
+
"advanced": { "cols": 3 },
|
|
55
|
+
"fields": [
|
|
56
|
+
{
|
|
57
|
+
"name": "card_number",
|
|
58
|
+
"type": "input/text",
|
|
59
|
+
"label": "Card number",
|
|
60
|
+
"advanced": { "cols": 2 }
|
|
61
|
+
},
|
|
62
|
+
{ "name": "cvc", "type": "input/text", "label": "CVC" }
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The card number takes two of the three columns and the CVC takes the third.
|
|
68
|
+
Spans of `1`, `2` and `3` are honoured and anything else is ignored, leaving the
|
|
69
|
+
field at one cell — unlike the container's `cols`, an out-of-range span does not
|
|
70
|
+
fall back to a default.
|
|
71
|
+
|
|
72
|
+
The same key means different things in the two places, which is worth reading
|
|
73
|
+
twice: on a `column` it is **how many columns the grid has**, on a field inside
|
|
74
|
+
it, **how many of them the field covers**.
|
|
75
|
+
|
|
76
|
+
A field's span outside a column does nothing at all. The class is emitted, the
|
|
77
|
+
parent is not a grid, and the field goes on filling the width.
|
|
78
|
+
|
|
79
|
+
### Description
|
|
80
|
+
|
|
81
|
+
A column's `description` renders **under** the grid, not above it, and unlike a
|
|
82
|
+
[section's description](sections.md#title-and-description-are-translated-not-interpolated)
|
|
83
|
+
it goes through interpolation: `{context.*}`, `{env.*}` and
|
|
84
|
+
[format filters](../interpolation/format-filters.md) all resolve. It also takes
|
|
85
|
+
the `{ title, message, collapsed }` object form that a field description takes.
|
|
86
|
+
|
|
87
|
+
### What a column can hold
|
|
88
|
+
|
|
89
|
+
Fields and [lists](../fields/list.md). A column inside a list row is supported
|
|
90
|
+
and keeps its grid; the fields inside it are named by the row's path like any
|
|
91
|
+
other leaf.
|
|
92
|
+
|
|
93
|
+
A column has no visibility of its own: `hidden` on a column is not read. Hide
|
|
94
|
+
the fields it holds instead — and note that when every field in a column is
|
|
95
|
+
hidden, the column counts as hidden for the
|
|
96
|
+
[section's visibility](sections.md#order-and-what-disappears), while the grid
|
|
97
|
+
element itself stays in the layout.
|
|
98
|
+
|
|
99
|
+
### Labels truncate inside a grid
|
|
100
|
+
|
|
101
|
+
A label that is longer than its cell is truncated to one line inside a column,
|
|
102
|
+
where the same label wraps outside one. This keeps the inputs of a row aligned
|
|
103
|
+
with each other instead of one sinking below its neighbour. Write the short
|
|
104
|
+
version in the `label` and put the rest in the `description`.
|
|
105
|
+
|
|
106
|
+
## Ordering fields and columns
|
|
107
|
+
|
|
108
|
+
Every entry in a `fields` array — a field, a column, a list — sorts by `order`
|
|
109
|
+
before rendering. Entries carrying one sort ascending; entries without one hold
|
|
110
|
+
their relative position and follow the rest. Because a column sorts as a single
|
|
111
|
+
entry, `order` interleaves whole columns with the plain fields around them, not
|
|
112
|
+
the fields inside them.
|
|
113
|
+
|
|
114
|
+
## Orientation (`advanced.horizontal`)
|
|
115
|
+
|
|
116
|
+
By default a field renders its label above its control. `advanced.horizontal`
|
|
117
|
+
puts them side by side from the `md` breakpoint up:
|
|
118
|
+
|
|
119
|
+
```json
|
|
120
|
+
{
|
|
121
|
+
"name": "timeout",
|
|
122
|
+
"type": "input/number",
|
|
123
|
+
"label": "Timeout",
|
|
124
|
+
"advanced": { "horizontal": true }
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
A form that wants every field that way sets it once as
|
|
129
|
+
`style.horizontal` in [`defineConfig`](../setup.md#style-alert-env). A field
|
|
130
|
+
that declares nothing inherits it; one that declares `false` opts out of it.
|
|
131
|
+
|
|
132
|
+
Two consequences:
|
|
133
|
+
|
|
134
|
+
- **`radio` and `checkbox` are always horizontal**, whatever the key or the
|
|
135
|
+
form-wide style says. Their control belongs beside its label, so neither is
|
|
136
|
+
read for them.
|
|
137
|
+
- **The description moves.** A vertical field renders its description after the
|
|
138
|
+
control; a horizontal one renders it under the label, in the label's own
|
|
139
|
+
column.
|
|
140
|
+
|
|
141
|
+
The rendered field carries `data-orientation="horizontal"` or `"vertical"`,
|
|
142
|
+
which is the attribute to key CSS on rather than the class names.
|
|
143
|
+
|
|
144
|
+
## `advanced.reverse` (checkbox only)
|
|
145
|
+
|
|
146
|
+
A `checkbox` renders its box first and its label after it. `reverse: false`
|
|
147
|
+
swaps them:
|
|
148
|
+
|
|
149
|
+
```json
|
|
150
|
+
{
|
|
151
|
+
"name": "accepted",
|
|
152
|
+
"type": "checkbox",
|
|
153
|
+
"label": "I accept the terms",
|
|
154
|
+
"advanced": { "reverse": false }
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The default is the reversed order, so this key exists to turn it **off**, and
|
|
159
|
+
`"reverse": true` changes nothing. It is read only for `checkbox`; on a `radio`,
|
|
160
|
+
an `input/*` or anything else it is ignored.
|
|
161
|
+
|
|
162
|
+
## Spacing
|
|
163
|
+
|
|
164
|
+
Spacing between fields is a property of the section that holds them, not of the
|
|
165
|
+
field: see [`advanced.compact`](sections.md#advancedcompact).
|
|
166
|
+
|
|
167
|
+
## Styling hooks
|
|
168
|
+
|
|
169
|
+
Every structural element carries a `data-slot`, so a design system can restyle
|
|
170
|
+
the scaffolding without patching the library. The components you register in
|
|
171
|
+
[`defineConfig`](../setup.md#inputs-required) are yours already; these are the
|
|
172
|
+
parts around them.
|
|
173
|
+
|
|
174
|
+
| `data-slot` | Wraps |
|
|
175
|
+
| ------------------- | ------------------------------------------------------- |
|
|
176
|
+
| `field-set` | one section, the rendered `<fieldset>` |
|
|
177
|
+
| `field-set-content` | the body of a collapsible section |
|
|
178
|
+
| `field-group` | the stack of entries inside a section |
|
|
179
|
+
| `field-separator` | the rule drawn by `advanced.separator` |
|
|
180
|
+
| `column` | the grid of a `column` |
|
|
181
|
+
| `field` | one field, with `data-orientation` on it |
|
|
182
|
+
| `field-label` | the `<label>` |
|
|
183
|
+
| `field-content` | the label and, when horizontal, the description with it |
|
|
184
|
+
| `field-control` | the form's own control area — where `children` renders |
|
|
185
|
+
| `list-item-card` | one row of a [list](../fields/list.md) |
|
|
186
|
+
|
|
187
|
+
A field also carries `data-invalid` while it holds an error and `data-readonly`
|
|
188
|
+
while it is disabled, both readable from the descendants with
|
|
189
|
+
`group-data-[...]`.
|