react-luna-form 0.0.82 → 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/config/cjs/index.js +1 -1
- package/dist/config/esm/index.js +1 -1
- package/dist/schema/cjs/index.js +1 -1
- package/dist/schema/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/type.d.ts +1 -1
- package/dist/types/luna-core/util/build.d.ts +1 -1
- package/dist/types/luna-core/util/extract.d.ts +4 -2
- package/dist/types/luna-core/util/is-type.d.ts +2 -1
- package/dist/types/luna-core/util/schema.d.ts +4 -4
- package/dist/types/luna-core/util/style.d.ts +1 -5
- package/dist/types/luna-react/src/client/component/input-selectable.d.ts +2 -2
- package/dist/types/luna-react/src/client/component/input-strategies.d.ts +19 -4
- package/dist/types/luna-react/src/client/hook/use-input-core.d.ts +3 -3
- package/docs/events/change.md +69 -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/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-[...]`.
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# Sections
|
|
2
|
+
|
|
3
|
+
`sections` is the top level of a form. Every other page describes what goes
|
|
4
|
+
inside one; this page describes the section itself.
|
|
5
|
+
|
|
6
|
+
A section is a **visual grouping and nothing else**. It draws a legend, sets the
|
|
7
|
+
spacing around its fields and can collapse them out of the way, but it does not
|
|
8
|
+
scope names: every field in the form shares one flat namespace, whichever
|
|
9
|
+
section it was declared in. Two fields called `email` in two different sections
|
|
10
|
+
are one field with two renderings, and
|
|
11
|
+
[`buildFormSchema`](../validation/overview.md#headless-validation) produces one
|
|
12
|
+
key for them.
|
|
13
|
+
|
|
14
|
+
## The shape
|
|
15
|
+
|
|
16
|
+
| Key | Type | Does |
|
|
17
|
+
| ---------------------- | ------------------ | --------------------------------------------- |
|
|
18
|
+
| `fields` | `Fields` | what the section renders |
|
|
19
|
+
| `title` | `string` | the legend |
|
|
20
|
+
| `description` | `string` | help text under the legend |
|
|
21
|
+
| `id` | `string \| number` | the `id` attribute of the rendered `fieldset` |
|
|
22
|
+
| `order` | `number` | position among the other sections |
|
|
23
|
+
| `advanced.compact` | `boolean` | tighter spacing between this section's fields |
|
|
24
|
+
| `advanced.collapsible` | `boolean` | render the section closed, behind a toggle |
|
|
25
|
+
| `advanced.separator` | `boolean` | draw a rule after the section |
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"sections": [
|
|
30
|
+
{
|
|
31
|
+
"id": "payment",
|
|
32
|
+
"title": "Payment Method",
|
|
33
|
+
"description": "All transactions are secure and encrypted",
|
|
34
|
+
"advanced": { "separator": true },
|
|
35
|
+
"fields": [{ "name": "name", "type": "input/text", "label": "Name" }]
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## A section with no title and no description draws nothing
|
|
42
|
+
|
|
43
|
+
There is no empty legend and no wrapper to style around it: the section's fields
|
|
44
|
+
join the flow of the form as if they had been declared without one. This is the
|
|
45
|
+
form to reach for when you want the grouping only for `compact` spacing or for
|
|
46
|
+
`order`.
|
|
47
|
+
|
|
48
|
+
Declaring `advanced.collapsible` on such a section does nothing either — with no
|
|
49
|
+
title there is nothing to click, so the fields render open.
|
|
50
|
+
|
|
51
|
+
## Title and description are translated, not interpolated
|
|
52
|
+
|
|
53
|
+
Both are resolved against the [`translations`](../fields/list.md#localization)
|
|
54
|
+
dictionary and both render `[text](url)` markdown links. Neither is passed
|
|
55
|
+
through interpolation: a `{context.name}` in a section title stays on screen as
|
|
56
|
+
the literal text `{context.name}`.
|
|
57
|
+
|
|
58
|
+
This is the one place the rule differs. A field's `label` and `description`
|
|
59
|
+
_are_ interpolated, and so is a [column's description](layout.md#description).
|
|
60
|
+
If a section heading has to carry a value, put the value in a field description
|
|
61
|
+
or in a column instead.
|
|
62
|
+
|
|
63
|
+
## `advanced.compact`
|
|
64
|
+
|
|
65
|
+
Spacing between the fields of this section, tight instead of loose. It is
|
|
66
|
+
declared per section:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"title": "Connection",
|
|
71
|
+
"advanced": { "compact": true },
|
|
72
|
+
"fields": [{ "name": "host", "type": "input/text" }]
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
A section that declares nothing inherits `style.compact` from
|
|
77
|
+
[`defineConfig`](../setup.md#style-alert-env), so the form-wide default holds
|
|
78
|
+
and a section only has to speak up to differ from it — including to opt out,
|
|
79
|
+
with `"compact": false` under a compact form.
|
|
80
|
+
|
|
81
|
+
## `advanced.separator`
|
|
82
|
+
|
|
83
|
+
Draws a horizontal rule **after** the section, between it and whatever follows.
|
|
84
|
+
On the last section it draws a trailing rule, which is rarely what you want.
|
|
85
|
+
|
|
86
|
+
## `advanced.collapsible`
|
|
87
|
+
|
|
88
|
+
Renders the section closed, with its title as the toggle:
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"title": "Advanced options",
|
|
93
|
+
"advanced": { "collapsible": true },
|
|
94
|
+
"fields": [{ "name": "timeout", "type": "input/number" }]
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Three things to know before using it:
|
|
99
|
+
|
|
100
|
+
- **It always starts closed.** There is no key that opens it, and reopening the
|
|
101
|
+
form does not remember that the user had expanded it.
|
|
102
|
+
- **The fields inside stay mounted.** Collapsing hides them; it does not unmount
|
|
103
|
+
them. They keep their values, they still validate, and they are still
|
|
104
|
+
submitted. That is the opposite of what a [`state` action](../events/change.md#what-clearing-a-hidden-target-means)
|
|
105
|
+
does when it hides a field, which unmounts it and clears the value.
|
|
106
|
+
- **The description moves inside.** It renders with the fields rather than under
|
|
107
|
+
the title, so it is not visible while the section is closed.
|
|
108
|
+
|
|
109
|
+
A collapsible section also renders no step number, whatever `advanced.step` says.
|
|
110
|
+
|
|
111
|
+
## `advanced.step` numbers the sections
|
|
112
|
+
|
|
113
|
+
Declared on the **form**, not on a section, it puts a numbered badge beside each
|
|
114
|
+
section's legend, counting the sections in the order they render:
|
|
115
|
+
|
|
116
|
+
```tsx
|
|
117
|
+
<Form advanced={{ step: true }} sections={sections} config={config} />
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
It is a numbering, not a wizard: every section is on screen at once, and the
|
|
121
|
+
library ships nothing that shows them one at a time. The badge appears only on
|
|
122
|
+
sections that draw a legend — a section with no title and no description has
|
|
123
|
+
nowhere to put it, and a collapsible one does not take it.
|
|
124
|
+
|
|
125
|
+
## `order`, and what disappears
|
|
126
|
+
|
|
127
|
+
`sections` renders in array order unless a section declares `order`. Sections
|
|
128
|
+
carrying one sort ascending; sections without one keep their relative order and
|
|
129
|
+
follow the rest. The same rule applies to the entries inside a section, so a
|
|
130
|
+
field and a column can be interleaved by `order` — see
|
|
131
|
+
[layout.md](layout.md#ordering-fields-and-columns).
|
|
132
|
+
|
|
133
|
+
Two kinds of section never render at all:
|
|
134
|
+
|
|
135
|
+
- **One with no fields.** `"fields": []` is dropped before rendering, legend
|
|
136
|
+
included.
|
|
137
|
+
- **One whose fields are all hidden.** Hidden by `hidden: true` in the JSON or
|
|
138
|
+
by a [`state` action](../events/change.md#2-state-action-stateevent) at run
|
|
139
|
+
time, it makes no difference: when nothing inside a section is visible the
|
|
140
|
+
section goes with it, and it comes back when one of them is shown again. A
|
|
141
|
+
column counts as hidden when every field in it is.
|
|
142
|
+
|
|
143
|
+
A section has **no visibility flag of its own**. Writing `"hidden": true` on a
|
|
144
|
+
section is accepted and ignored; hide its fields instead, or leave the section
|
|
145
|
+
out of the array.
|
|
146
|
+
|
|
147
|
+
## `id`
|
|
148
|
+
|
|
149
|
+
Lands on the rendered `<fieldset>` as its `id` attribute, for an anchor link or
|
|
150
|
+
a test selector. It is not a name and nothing resolves against it: fields are
|
|
151
|
+
addressed by their own `name` from anywhere in the form.
|
|
152
|
+
|
|
153
|
+
## Complete example
|
|
154
|
+
|
|
155
|
+
```json
|
|
156
|
+
{
|
|
157
|
+
"advanced": { "step": true },
|
|
158
|
+
"sections": [
|
|
159
|
+
{
|
|
160
|
+
"id": "contact",
|
|
161
|
+
"title": "Contact",
|
|
162
|
+
"description": "How we reach you about this order",
|
|
163
|
+
"advanced": { "separator": true },
|
|
164
|
+
"fields": [
|
|
165
|
+
{ "name": "email", "type": "input/email", "label": "Email" },
|
|
166
|
+
{ "name": "phone", "type": "input/tel", "label": "Phone" }
|
|
167
|
+
]
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
"title": "Delivery",
|
|
171
|
+
"advanced": { "compact": true },
|
|
172
|
+
"fields": [
|
|
173
|
+
{ "name": "street", "type": "input/text", "label": "Street" },
|
|
174
|
+
{ "name": "city", "type": "input/text", "label": "City" }
|
|
175
|
+
]
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
"title": "Advanced options",
|
|
179
|
+
"advanced": { "collapsible": true },
|
|
180
|
+
"order": 99,
|
|
181
|
+
"fields": [
|
|
182
|
+
{ "name": "notes", "type": "textarea", "label": "Delivery notes" }
|
|
183
|
+
]
|
|
184
|
+
}
|
|
185
|
+
]
|
|
186
|
+
}
|
|
187
|
+
```
|
|
@@ -251,7 +251,7 @@ if (!result.success) {
|
|
|
251
251
|
}
|
|
252
252
|
```
|
|
253
253
|
|
|
254
|
-
- **`buildFormSchema(sections, translations?, definition?)`** — walks the form tree (sections → columns → lists → fields) and returns a Zod schema. Lists become arrays of objects, so nested `requiredWhen`/`rules` resolve in **item scope**. `translations` maps messages to localized strings; `definition` resolves `$ref`
|
|
254
|
+
- **`buildFormSchema(sections, translations?, definition?)`** — walks the form tree (sections → columns → lists → fields) and returns a Zod schema. Lists become arrays of objects, so nested `requiredWhen`/`rules` resolve in **item scope**. `translations` maps messages to localized strings; `definition` resolves [`$ref` entries](../structure/definition.md), and has to be the same one the form renders with.
|
|
255
255
|
- **`collectIssues(error)`** — flattens a `ZodError` into `{ path, message }` entries with **dotted paths** (e.g. `rules.0.value`), including nested list issues.
|
|
256
256
|
|
|
257
257
|
### What each path validates
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-luna-form",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.84",
|
|
4
4
|
"description": "A React library for building forms based on JSON",
|
|
5
5
|
"main": "./dist/client/cjs/index.js",
|
|
6
6
|
"module": "./dist/client/esm/index.js",
|
|
@@ -31,11 +31,11 @@
|
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@types/react": "19.2.18",
|
|
34
|
-
"@types/react-dom": "19.2.
|
|
34
|
+
"@types/react-dom": "19.2.5",
|
|
35
35
|
"eslint-plugin-react": "7.37.5",
|
|
36
36
|
"eslint-plugin-react-hooks": "7.1.1",
|
|
37
|
-
"eslint-plugin-react-refresh": "0.5.
|
|
38
|
-
"@luna-form/core": "0.0.
|
|
37
|
+
"eslint-plugin-react-refresh": "0.5.5",
|
|
38
|
+
"@luna-form/core": "0.0.84"
|
|
39
39
|
},
|
|
40
40
|
"license": "Apache-2.0",
|
|
41
41
|
"repository": {
|