bare-tui-form 0.0.0 → 0.0.2
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 +525 -0
- package/fields/action.js +63 -0
- package/fields/base.js +213 -0
- package/fields/confirm.js +46 -0
- package/fields/constant.js +41 -0
- package/fields/file.js +129 -0
- package/fields/list.js +158 -0
- package/fields/multiselect.js +97 -0
- package/fields/number.js +60 -0
- package/fields/radio.js +32 -0
- package/fields/section.js +71 -0
- package/fields/select.js +46 -0
- package/fields/text.js +35 -0
- package/fields/textarea.js +43 -0
- package/form.js +1028 -0
- package/harden.js +173 -0
- package/index.js +86 -0
- package/package.json +52 -1
- package/prompt.js +107 -0
- package/schema.js +620 -0
- package/structural.js +62 -0
- package/theme.js +86 -0
package/README.md
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
# bare-tui-form
|
|
2
|
+
|
|
3
|
+
A declarative form builder for [bare-tui](../bare-tui).
|
|
4
|
+
|
|
5
|
+
> [!NOTE]
|
|
6
|
+
> This an experimental library. A version 1.0.0 release will signal stability.
|
|
7
|
+
|
|
8
|
+
bare-tui ships the field _controls_ (textinput, textarea, select, radio, checkbox) and a focus ring, and deliberately stops there. This package adds the opinionated layer on top — labels, descriptions, per-field validation, focus movement, and submission — so you describe a form as data and get a working, validated, keyboard-driven UI.
|
|
9
|
+
|
|
10
|
+
```js
|
|
11
|
+
const form = require('bare-tui-form')
|
|
12
|
+
|
|
13
|
+
const f = form.create({
|
|
14
|
+
title: 'Create your account',
|
|
15
|
+
fields: [
|
|
16
|
+
form.text({ name: 'name', label: 'Name', required: true }),
|
|
17
|
+
form.text({ name: 'email', label: 'Email', validate: isEmail }),
|
|
18
|
+
form.select({ name: 'plan', label: 'Plan', options: ['free', 'pro'] }),
|
|
19
|
+
form.confirm({ name: 'tos', label: 'Accept terms', required: true })
|
|
20
|
+
]
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const values = await form.run(f) // { name, email, plan, tos } | null if cancelled
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
bare examples/signup.js
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Running a form
|
|
31
|
+
|
|
32
|
+
Two ways, depending on whether the form owns the screen or lives inside a larger app.
|
|
33
|
+
|
|
34
|
+
### Standalone — `form.run(f)`
|
|
35
|
+
|
|
36
|
+
`run()` wires the form into a bare-tui `Program`, restores the terminal when it's done, and resolves with the collected values — or `null` if the user cancels with `ctrl+c`.
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
const values = await form.run(f)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Embedded — handle the messages
|
|
43
|
+
|
|
44
|
+
A form is a normal bare-tui component (`update`/`view`). Embed it and react to the two messages it emits:
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
update(msg) {
|
|
48
|
+
if (msg.type === 'form.submit') return [this, save(msg.values)]
|
|
49
|
+
if (msg.type === 'form.cancel') return [this, quit]
|
|
50
|
+
const [m, cmd] = this.form.update(msg)
|
|
51
|
+
this.form = m
|
|
52
|
+
return [this, cmd]
|
|
53
|
+
}
|
|
54
|
+
view() { return this.form.view() }
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The form **never calls `quit`** — it only emits `form.submit` / `form.cancel`. So embedding it never tears the terminal down: on submit you can switch to a result view, swap in another form, or keep going, and your app stays live until _you_ decide to `quit`. (That's the difference from `run()`, which calls `quit` for you.) See [`examples/embedded.js`](examples/embedded.js) for a host app that shows a form, then continues running after submit.
|
|
58
|
+
|
|
59
|
+
`form.value()` and `form.errors()` are also available any time if you'd rather poll than react to messages.
|
|
60
|
+
|
|
61
|
+
## Fields
|
|
62
|
+
|
|
63
|
+
Each factory returns a field; pass them to `form.create({ fields: [...] })`.
|
|
64
|
+
|
|
65
|
+
| Factory | Control | `value()` |
|
|
66
|
+
| ------------------------ | ---------------- | ----------------- |
|
|
67
|
+
| `form.text(opts)` | single-line text | `string` |
|
|
68
|
+
| `form.textarea(opts)` | multi-line text | `string` |
|
|
69
|
+
| `form.number(opts)` | numeric text | `number \| null` |
|
|
70
|
+
| `form.select(opts)` | dropdown | the chosen value |
|
|
71
|
+
| `form.radio(opts)` | expanded choices | the chosen value |
|
|
72
|
+
| `form.confirm(opts)` | checkbox | `boolean` |
|
|
73
|
+
| `form.multiselect(opts)` | toggle list | `array` of values |
|
|
74
|
+
|
|
75
|
+
Common options on every field: `name` (the key in `form.value()`), `label`, `description`, `required`, `requiredMessage`, and `validate`. `select`, `radio`, and `multiselect` take `options` (strings or `{ label, value }`). `number` takes `min` / `max` / `integer`. `confirm` treats `required` as "must be checked".
|
|
76
|
+
|
|
77
|
+
**Presentation options (the same ones [uiSchema](#presentation-uischema) exposes):** every field also accepts `help` (a hint line), `placeholder`, `hideLabel`, `autofocus`, `readonly` (shown, not editable, still collected), and `hidden` (collected, never shown). `text` takes `echoMode: 'password'`; `textarea` takes `rows`. These live on the field, not the schema layer — so a hand-built form gets them for free, and `fromSchema`'s uiSchema is just sugar that maps `ui:*` names onto them. (Field order is just the order you list them; choosing a control is just choosing the factory.)
|
|
78
|
+
|
|
79
|
+
### Structural fields (nested objects & arrays)
|
|
80
|
+
|
|
81
|
+
`form.group` and `form.array` give hand-built forms the same structure `fromSchema` produces, and you can **mix instances, groups, and arrays in one `fields` array**:
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
form.create({
|
|
85
|
+
fields: [
|
|
86
|
+
form.text({ name: 'name', autofocus: true }),
|
|
87
|
+
form.group({
|
|
88
|
+
name: 'address',
|
|
89
|
+
title: 'Address',
|
|
90
|
+
optional: false,
|
|
91
|
+
fields: [form.text({ name: 'street' }), form.text({ name: 'city' })]
|
|
92
|
+
}),
|
|
93
|
+
form.array({
|
|
94
|
+
name: 'phones',
|
|
95
|
+
minItems: 1,
|
|
96
|
+
removable: false,
|
|
97
|
+
fields: [
|
|
98
|
+
{ type: 'text', name: 'label' }, // item fields are DEFS, not instances
|
|
99
|
+
{ type: 'text', name: 'number' }
|
|
100
|
+
]
|
|
101
|
+
})
|
|
102
|
+
]
|
|
103
|
+
})
|
|
104
|
+
// value → { name, address: { street, city }, phones: [ { label, number }, … ] }
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`form.group({ optional: true })` is a checkbox-gated section. `form.array` takes `minItems`/`maxItems`/`addable`/`removable`. Its **item fields must be plain `{ type, name }` defs, not field instances** — each entry needs its own field, so a shared instance would bleed values between rows (the factory throws if you pass an instance).
|
|
108
|
+
|
|
109
|
+
### Validation
|
|
110
|
+
|
|
111
|
+
`validate(value)` returns an error string when invalid, or a falsy value when fine. `required` is checked first, then your validator.
|
|
112
|
+
|
|
113
|
+
```js
|
|
114
|
+
form.text({
|
|
115
|
+
name: 'email',
|
|
116
|
+
required: true,
|
|
117
|
+
validate: (v) => (v.includes('@') ? null : 'need an @')
|
|
118
|
+
})
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Validation runs when you confirm a field (enter) and again on submit. Enter won't leave an invalid field, and submit jumps focus to the first error.
|
|
122
|
+
|
|
123
|
+
### Async validation (spinner included)
|
|
124
|
+
|
|
125
|
+
Make `validate` an `async` function and the form handles the rest: while it runs it shows a spinner next to the field, gates input so the check can't be raced, and then either shows the returned error or advances. Nothing else to wire up.
|
|
126
|
+
|
|
127
|
+
```js
|
|
128
|
+
form.text({
|
|
129
|
+
name: 'username',
|
|
130
|
+
label: 'Username',
|
|
131
|
+
required: true,
|
|
132
|
+
validatingMessage: 'checking availability…', // shown beside the spinner
|
|
133
|
+
validate: async (value) => {
|
|
134
|
+
const available = await api.checkUsername(value) // your real API call
|
|
135
|
+
return available ? null : 'that username is taken'
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Under the hood this runs as a bare-tui Cmd and comes back as a message, stamped with a per-field run id so a cancelled or superseded check can never apply a stale result. An async check only fires for a non-empty value (an empty optional field just advances). `ctrl+c` cancels an in-flight check. See [`examples/async-validation.js`](examples/async-validation.js).
|
|
141
|
+
|
|
142
|
+
#### Async checks on submit
|
|
143
|
+
|
|
144
|
+
A user can fill a form without confirming every field, so an async check might never have run by the time they submit. By default the form closes that gap: **on submit it re-runs any async validators that are dirty** (never ran, or whose value changed since they last passed), **one at a time**, behind a single footer spinner that counts progress (`validating… 2/3`). Input is gated while it runs; the first failure stops the run and focuses that field; if they all pass, the form submits. Serial (not parallel) keeps load predictable when checks hit a real API.
|
|
145
|
+
|
|
146
|
+
This is on by default. Opt out explicitly — and then **you** own making sure those checks ran:
|
|
147
|
+
|
|
148
|
+
```js
|
|
149
|
+
form.create({
|
|
150
|
+
validateAsyncOnSubmit: false, // submit immediately; don't re-run async checks
|
|
151
|
+
fields: [...]
|
|
152
|
+
})
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
A field that already passed its check (e.g. you confirmed it with `enter`) isn't re-run unless you edit it again. See [`examples/async-submit.js`](examples/async-submit.js).
|
|
156
|
+
|
|
157
|
+
## Styling
|
|
158
|
+
|
|
159
|
+
Pass a `theme` to `form.create({ theme })` to restyle the form chrome. A theme is a bag of `string → string` style functions plus two markers; your partial theme is merged over the defaults, so you override only what you want.
|
|
160
|
+
|
|
161
|
+
```js
|
|
162
|
+
const { style } = require('bare-tui')
|
|
163
|
+
|
|
164
|
+
form.create({
|
|
165
|
+
theme: {
|
|
166
|
+
title: (s) => style().bold(true).foreground('magenta').render(s),
|
|
167
|
+
labelFocused: (s) => style().bold(true).foreground('cyan').render(s),
|
|
168
|
+
error: (s) => style().foreground('yellow').render(s),
|
|
169
|
+
spinner: { frames: 'line', fps: 12 } // 'dots' | 'line' | 'points' or an array
|
|
170
|
+
},
|
|
171
|
+
fields: [...]
|
|
172
|
+
})
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
| Theme key | Styles |
|
|
176
|
+
| ------------------------ | -------------------------------------------- |
|
|
177
|
+
| `title` | the form title |
|
|
178
|
+
| `label` / `labelFocused` | a field label (blurred / focused) |
|
|
179
|
+
| `description` | a field's description line |
|
|
180
|
+
| `error` | an error message |
|
|
181
|
+
| `validating` | the spinner + validating message line |
|
|
182
|
+
| `help` | the footer hint |
|
|
183
|
+
| `sectionTitle` | a nested-object / array section heading |
|
|
184
|
+
| `requiredMarker` | string appended to a required label (`' *'`) |
|
|
185
|
+
| `errorPrefix` | string prefixed to an error (`'✗ '`) |
|
|
186
|
+
| `spinner` | `{ frames, fps }` for the async spinner |
|
|
187
|
+
| `frame` | wrap the whole form in a border / background |
|
|
188
|
+
|
|
189
|
+
Field _controls_ (the textinput cursor, the select dropdown colours) are styled by bare-tui itself; the theme here covers the form's own chrome.
|
|
190
|
+
|
|
191
|
+
### Framing the form (borders & backgrounds)
|
|
192
|
+
|
|
193
|
+
`theme.frame` wraps the whole rendered form in a border / background / padding. The form measures the rows your frame adds, so scrolling still fits the terminal. There are four ways to set it, easiest first:
|
|
194
|
+
|
|
195
|
+
```js
|
|
196
|
+
// 1. a ready-made preset
|
|
197
|
+
form.create({ theme: { frame: form.frames.rounded } }) // .normal / .thick / .double
|
|
198
|
+
|
|
199
|
+
// 2. a descriptor object (no style() chain)
|
|
200
|
+
form.create({
|
|
201
|
+
theme: {
|
|
202
|
+
frame: {
|
|
203
|
+
border: 'rounded',
|
|
204
|
+
color: '#a78bfa',
|
|
205
|
+
background: '#0d0b1f',
|
|
206
|
+
padding: [1, 3],
|
|
207
|
+
width: 56
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// 3. the builder (same options, reusable)
|
|
213
|
+
const fancy = form.frame({ border: 'rounded', color: '#a78bfa', padding: [1, 3] })
|
|
214
|
+
form.create({ theme: { frame: fancy } })
|
|
215
|
+
|
|
216
|
+
// 4. a function, for full control
|
|
217
|
+
const { style } = require('bare-tui')
|
|
218
|
+
form.create({
|
|
219
|
+
theme: { frame: (s) => style().border(style.borders.rounded).padding(1, 2).render(s) }
|
|
220
|
+
})
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
`frame` options: `border` (`'normal' | 'rounded' | 'thick' | 'double'`, or a bare-tui border object), `color` (the border line colour), `background` (fills the box — content keeps its own colours), `padding` (a number or `[v, h]`), `width`, `align`.
|
|
224
|
+
|
|
225
|
+
Because every chrome key is just a `string → string` function over `style` (which supports truecolor `#rrggbb`), you can go as far as gradients and accent "glow" on focus. [`examples/glow.js`](examples/glow.js) is a full dressed-up form — gradient title, rounded truecolor frame, dark background, cyan focus accent — to crib from.
|
|
226
|
+
|
|
227
|
+
## Keys
|
|
228
|
+
|
|
229
|
+
| Key | Action |
|
|
230
|
+
| ------------------- | ------------------------------------------------------------------------------- |
|
|
231
|
+
| `tab` / `shift+tab` | move between fields (no validation — a free escape hatch) |
|
|
232
|
+
| `enter` | confirm the focused field and advance; on a button (array add/remove), press it |
|
|
233
|
+
| `ctrl+s` | **submit** the form (emits `form.submit`) |
|
|
234
|
+
| `ctrl+c` | cancel (emits `form.cancel`) |
|
|
235
|
+
| `pageup`/`pagedown` | scroll, when the form is taller than the terminal |
|
|
236
|
+
|
|
237
|
+
**Submit is deliberate, not `enter`.** Enter confirms a field, advances, or presses a button — it never submits. Submitting is its own key (`ctrl+s` by default) so a form that ends in an array's **+ Add** button can't be submitted by accident. (The default is `ctrl+s` rather than a modifier+enter because most terminals can't distinguish `ctrl`/`shift+enter` from a plain `enter` — they all arrive as `\r`. In raw mode `ctrl+s` is delivered to the app, not swallowed as flow control.)
|
|
238
|
+
|
|
239
|
+
Some controls own keys while focused: `textarea` keeps `enter` for newlines (use `tab` to move on), an open `select` uses `↑`/`↓`/`enter`/`esc` for its menu, `radio` uses the arrows, and `space` toggles `confirm`/`multiselect`.
|
|
240
|
+
|
|
241
|
+
### Configuring the keys
|
|
242
|
+
|
|
243
|
+
Every form-level binding is configurable — pass a partial `keys` map (merged over the defaults). Each action is an array of chords.
|
|
244
|
+
|
|
245
|
+
```js
|
|
246
|
+
form.create({
|
|
247
|
+
keys: {
|
|
248
|
+
submit: ['alt+enter'], // if your terminal can distinguish it
|
|
249
|
+
cancel: ['esc'],
|
|
250
|
+
next: ['tab', 'ctrl+n'],
|
|
251
|
+
prev: ['shift+tab', 'ctrl+p']
|
|
252
|
+
},
|
|
253
|
+
fields: [...]
|
|
254
|
+
})
|
|
255
|
+
// keys: { submit, cancel, confirm, next, prev, scrollUp, scrollDown }
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### Scrolling
|
|
259
|
+
|
|
260
|
+
When the form is taller than the terminal, the body **scrolls to keep the focused field visible** — as you `tab` down past the fold, the view follows. The title stays pinned at the top and the help footer at the bottom; `pageup`/`pagedown` scroll a page at a time. This needs the terminal height: `form.run()` gets it automatically, and an embedded form gets it as long as you forward the `{ type: 'resize' }` message into `form.update()` (which you already do if you forward all messages).
|
|
261
|
+
|
|
262
|
+
## Forms from JSON Schema
|
|
263
|
+
|
|
264
|
+
`form.fromSchema(jsonSchema)` builds a form from a JSON Schema. Because LLMs are good at producing and consuming JSON Schema, this is the pairing it's built for: a model describes the questions it needs answered as a schema, this turns it into a real terminal form, and the answers come back as a matching JSON object.
|
|
265
|
+
|
|
266
|
+
```js
|
|
267
|
+
const f = form.fromSchema({
|
|
268
|
+
type: 'object',
|
|
269
|
+
title: 'New project',
|
|
270
|
+
required: ['name', 'license'],
|
|
271
|
+
properties: {
|
|
272
|
+
name: { type: 'string', title: 'Project name', minLength: 2 },
|
|
273
|
+
license: {
|
|
274
|
+
type: 'string',
|
|
275
|
+
enum: ['MIT', 'Apache-2.0', 'none'],
|
|
276
|
+
enumNames: ['MIT', 'Apache 2.0', 'No license']
|
|
277
|
+
},
|
|
278
|
+
private: { type: 'boolean', default: false },
|
|
279
|
+
features: { type: 'array', items: { enum: ['tests', 'ci', 'docs'] }, default: ['tests'] },
|
|
280
|
+
teamSize: { type: 'integer', minimum: 1, maximum: 50, default: 1 }
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
const values = await form.run(f) // { name, license, private, features, teamSize }
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
```sh
|
|
288
|
+
bare examples/schema.js
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
It's a thin mapping onto the field definitions below — `fromSchema` produces the same `{ type, name, … }` objects you can write by hand (see [`examples/from-objects.js`](examples/from-objects.js)).
|
|
292
|
+
|
|
293
|
+
### Rehydrating existing values
|
|
294
|
+
|
|
295
|
+
Pass `formData` to pre-fill the form with an existing object (like react-jsonschema-form). It overrides schema `default`s; properties it omits keep their default. This makes "edit this record" forms a one-liner, and round-trips: `form.run` gives you back the same shape.
|
|
296
|
+
|
|
297
|
+
```js
|
|
298
|
+
const f = form.fromSchema(schema, {
|
|
299
|
+
formData: { name: 'Ada', license: 'MIT', private: true, features: ['tests', 'ci'], teamSize: 4 }
|
|
300
|
+
})
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
For any form (schema-built or hand-written), `form.setValues(obj)` does the same thing on demand — it's the inverse of `form.value()`.
|
|
304
|
+
|
|
305
|
+
### Untrusted schemas (security)
|
|
306
|
+
|
|
307
|
+
A JSON Schema is often **untrusted** — an LLM produced it, or it arrived from a peer. `fromSchema` treats it that way, because it's the boundary where someone else's data becomes regexes you compile and text you paint onto a terminal. It's hardened by default:
|
|
308
|
+
|
|
309
|
+
- **Terminal-control characters are stripped** from every rendered string (title, labels, descriptions, option labels) and from rehydrated `formData`. A label like `"\x1b]0;…"` can't move the cursor, repaint the screen, or set the window title.
|
|
310
|
+
- **`pattern` regexes are screened for ReDoS.** A catastrophic-backtracking pattern (`(a+)+$` and friends) is refused rather than compiled, the source length is capped, and matching runs against bounded input — so a schema can't hang the event loop. Invalid regexes are dropped, not thrown.
|
|
311
|
+
- **Prototype-pollution property names** (`__proto__`, `constructor`, `prototype`) are refused, in the schema and in `formData`.
|
|
312
|
+
- **Numeric constraints are validated** — a `minLength: "evil"` or `minimum: NaN` is ignored, not trusted.
|
|
313
|
+
- **Sizes are capped** — too many properties or too large an enum throws a clear error instead of building a multi-thousand-field form.
|
|
314
|
+
|
|
315
|
+
Anything dropped or clamped is reported on `form.warnings` (an array of strings) rather than silently swallowed:
|
|
316
|
+
|
|
317
|
+
```js
|
|
318
|
+
const f = form.fromSchema(untrustedSchema)
|
|
319
|
+
if (f.warnings.length) console.error('schema hardened:', f.warnings)
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
If you **trust** the source, opt back into full fidelity:
|
|
323
|
+
|
|
324
|
+
```js
|
|
325
|
+
form.fromSchema(schema, {
|
|
326
|
+
trusted: true, // compile `pattern` regexes verbatim (no ReDoS screen)
|
|
327
|
+
limits: { maxFields: 1000, maxEnum: 5000 } // raise the size caps
|
|
328
|
+
})
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
(Hand-written forms via `form.create` are your own code, so they aren't sanitized. If you feed untrusted values into `form.setValues` outside of `fromSchema`, clean them yourself — `require('bare-tui-form/harden').cleanText` is the same helper.)
|
|
332
|
+
|
|
333
|
+
### Supported subset
|
|
334
|
+
|
|
335
|
+
Tracking a useful slice of [react-jsonschema-form](https://github.com/rjsf-team/react-jsonschema-form), starting reasonable:
|
|
336
|
+
|
|
337
|
+
| Schema | Becomes |
|
|
338
|
+
| ---------------------------------- | ------------------------------------------ |
|
|
339
|
+
| `type: 'object'` with `properties` | a form, one field per property |
|
|
340
|
+
| a **nested** `type: 'object'` | a sub-section (subform / optional section) |
|
|
341
|
+
| `oneOf` / `anyOf` | a variant selector (or a select if scalar) |
|
|
342
|
+
| `if` / `then` / `else` | conditional fields driven by a controller |
|
|
343
|
+
| `const` | a fixed, non-interactive value |
|
|
344
|
+
| `type: 'string'` | text field |
|
|
345
|
+
| `type: 'number'` / `'integer'` | number field (`minimum`/`maximum`/integer) |
|
|
346
|
+
| `type: 'boolean'` | confirm (checkbox) |
|
|
347
|
+
| any `enum` | select (single choice) |
|
|
348
|
+
| `type: 'array'` + `items.enum` | multiselect (choose many) |
|
|
349
|
+
| `type: 'array'` + object `items` | a repeatable subform (add/remove entries) |
|
|
350
|
+
| `required: [...]` | required fields |
|
|
351
|
+
| `title` / `description` | labels & help text (form- and field-level) |
|
|
352
|
+
| `default` | initial values |
|
|
353
|
+
| `enumNames` | option labels |
|
|
354
|
+
| `minLength` / `pattern` / `format` | string validation |
|
|
355
|
+
| `maxLength` | caps input |
|
|
356
|
+
|
|
357
|
+
Booleans aren't forced `true` by `required` (a checkbox always has a value), matching JSON Schema. Formats currently cover `email` and `uri`.
|
|
358
|
+
|
|
359
|
+
### Nested objects (subforms & optional sections)
|
|
360
|
+
|
|
361
|
+
A `type: 'object'` property nested inside another becomes a **sub-section**, and its `required` status decides the UX:
|
|
362
|
+
|
|
363
|
+
- A **required** nested object is an always-included **subform** — a heading and its fields, indented.
|
|
364
|
+
- A **non-required** nested object is an **optional section** with a checkbox gate. It auto-checks the moment the user starts filling any field inside it; toggling it off omits the whole subtree from the result. The fields stay visible the whole time — the checkbox controls _inclusion_, not visibility — and an included section's own `required` fields are validated, so a half-filled section is caught.
|
|
365
|
+
|
|
366
|
+
The collected value is nested to match the schema:
|
|
367
|
+
|
|
368
|
+
```js
|
|
369
|
+
const f = form.fromSchema({
|
|
370
|
+
type: 'object',
|
|
371
|
+
required: ['name', 'address'],
|
|
372
|
+
properties: {
|
|
373
|
+
name: { type: 'string' },
|
|
374
|
+
address: {
|
|
375
|
+
// required → an always-included subform
|
|
376
|
+
type: 'object',
|
|
377
|
+
title: 'Address',
|
|
378
|
+
required: ['street'],
|
|
379
|
+
properties: { street: { type: 'string' }, city: { type: 'string' } }
|
|
380
|
+
},
|
|
381
|
+
billing: {
|
|
382
|
+
// not required → an optional section the user gates with a checkbox
|
|
383
|
+
type: 'object',
|
|
384
|
+
title: 'Billing',
|
|
385
|
+
properties: { card: { type: 'string' } }
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
const values = await form.run(f)
|
|
391
|
+
// { name, address: { street, city } } ← billing left off
|
|
392
|
+
// { name, address: { street, city }, billing: { card } } ← billing filled in
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
`form.value()`, `setValues()`, and `errors()` are all path-aware: `setValues` rehydrates a nested object (and arms the gate of any section present in the data), and `errors()` keys by dotted path (`'address.street'`). Nesting is bounded by a `maxDepth` limit and the global `maxFields` cap (see the security section). See [`examples/nested.js`](examples/nested.js).
|
|
396
|
+
|
|
397
|
+
### Dynamic forms (`oneOf` / `anyOf` / `if`-`then`-`else`)
|
|
398
|
+
|
|
399
|
+
The form set can change based on a value — a selector, or another field's value. Hidden fields aren't focusable, collected, or validated; the form recomputes live as you type.
|
|
400
|
+
|
|
401
|
+
**`oneOf` / `anyOf` on a property → a variant.** A selector picks which branch applies, and only that branch's fields are live. The chosen branch's data nests under the property. (`anyOf` is rendered the same as `oneOf` — pick one — matching react-jsonschema-form's UI.) When every branch is a `const`/`enum` scalar it collapses to a plain select instead. A `const` property in a branch (the common discriminator) is collected but not editable.
|
|
402
|
+
|
|
403
|
+
```js
|
|
404
|
+
const f = form.fromSchema({
|
|
405
|
+
type: 'object',
|
|
406
|
+
properties: {
|
|
407
|
+
payment: {
|
|
408
|
+
title: 'Payment method',
|
|
409
|
+
oneOf: [
|
|
410
|
+
{
|
|
411
|
+
title: 'Credit card',
|
|
412
|
+
properties: { kind: { const: 'card' }, number: { type: 'string' } }
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
title: 'PayPal',
|
|
416
|
+
properties: { kind: { const: 'paypal' }, email: { type: 'string', format: 'email' } }
|
|
417
|
+
}
|
|
418
|
+
]
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
})
|
|
422
|
+
// → { payment: { kind: 'card', number } } or { payment: { kind: 'paypal', email } }
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
**`if` / `then` / `else` on an object → conditional fields.** When the `if` matches the current values, the `then` fields (and their `required`) go live; otherwise the `else` fields. The supported `if` is the common discriminator shape — `{ properties: { field: { const } | { enum: [...] } } }` — matched against sibling controllers.
|
|
426
|
+
|
|
427
|
+
```js
|
|
428
|
+
form.fromSchema({
|
|
429
|
+
type: 'object',
|
|
430
|
+
properties: { country: { type: 'string', enum: ['US', 'other'] } },
|
|
431
|
+
if: { properties: { country: { const: 'US' } } },
|
|
432
|
+
then: { properties: { zip: { type: 'string' } }, required: ['zip'] },
|
|
433
|
+
else: { properties: { postalCode: { type: 'string' } } }
|
|
434
|
+
})
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
Rehydration handles both: `formData` points a variant's selector at the branch its shape best matches, and conditionals resolve from the controller's value. See [`examples/dynamic.js`](examples/dynamic.js).
|
|
438
|
+
|
|
439
|
+
### Repeating sections (arrays of objects)
|
|
440
|
+
|
|
441
|
+
An `array` whose `items` is an object becomes a **repeatable subform**: a list of entries the user grows with **+ Add** and shrinks with a per-entry **✕ Remove**, each entry being the item object's fields. The result is a real array.
|
|
442
|
+
|
|
443
|
+
```js
|
|
444
|
+
const f = form.fromSchema({
|
|
445
|
+
type: 'object',
|
|
446
|
+
properties: {
|
|
447
|
+
contacts: {
|
|
448
|
+
type: 'array',
|
|
449
|
+
title: 'Contacts',
|
|
450
|
+
minItems: 1,
|
|
451
|
+
maxItems: 5,
|
|
452
|
+
items: {
|
|
453
|
+
type: 'object',
|
|
454
|
+
title: 'Contact',
|
|
455
|
+
required: ['name'],
|
|
456
|
+
properties: {
|
|
457
|
+
name: { type: 'string', title: 'Name' },
|
|
458
|
+
email: { type: 'string', title: 'Email', format: 'email' }
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
})
|
|
464
|
+
// → { contacts: [ { name, email }, … ] }
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
`minItems`/`maxItems` bound the count (the add button hides at the max; remove hides at the min), and each entry's own `required` fields are validated. Entries are tracked by a stable identity, so removing the one in the middle shifts the rest down **without scrambling their values**, and `formData` rehydrates the array to the right length. Reach the buttons with `tab`; `enter` moves through the data fields and submits past them. See [`examples/array.js`](examples/array.js).
|
|
468
|
+
|
|
469
|
+
### Presentation (uiSchema)
|
|
470
|
+
|
|
471
|
+
The schema says _what_ to collect; a [react-jsonschema-form](https://rjsf-team.github.io/react-jsonschema-form/docs/api-reference/uiSchema/)-style **`uiSchema`** says _how_ to present it. Pass it as `form.fromSchema(schema, { uiSchema })`. It's a parallel tree keyed by property name (`items` for array items, nested objects recurse). This is a terminal, not a responsive web page, so the subset is the parts that mean something here — no CSS/layout.
|
|
472
|
+
|
|
473
|
+
```js
|
|
474
|
+
const f = form.fromSchema(schema, {
|
|
475
|
+
uiSchema: {
|
|
476
|
+
'ui:order': ['title', 'rating', '*'], // field order; '*' = everything else
|
|
477
|
+
title: { 'ui:autofocus': true, 'ui:placeholder': 'A headline' },
|
|
478
|
+
bio: { 'ui:widget': 'textarea', 'ui:options': { rows: 6 }, 'ui:help': 'markdown ok' },
|
|
479
|
+
pin: { 'ui:widget': 'password' },
|
|
480
|
+
role: { 'ui:widget': 'radio' }, // an enum as radio instead of a dropdown
|
|
481
|
+
source: { 'ui:widget': 'hidden' }, // collected, never shown
|
|
482
|
+
plan: { 'ui:readonly': true } // shown, not editable, still collected
|
|
483
|
+
}
|
|
484
|
+
})
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
| uiSchema key | Effect |
|
|
488
|
+
| ------------------------------------ | ----------------------------------------------------------------- |
|
|
489
|
+
| `ui:order` | property order; `'*'` stands in for the unlisted ones |
|
|
490
|
+
| `ui:title` / `ui:description` | override the schema's label / help text |
|
|
491
|
+
| `ui:help` | an extra hint line under the field |
|
|
492
|
+
| `ui:placeholder` | placeholder for text/number/select |
|
|
493
|
+
| `ui:label: false` | hide the field's label |
|
|
494
|
+
| `ui:widget` | `textarea` (+`ui:rows`), `password`, `radio`, `hidden`, or custom |
|
|
495
|
+
| `ui:autofocus` | start focus on this field |
|
|
496
|
+
| `ui:disabled` / `ui:readonly` | non-interactive (shown, value still collected) |
|
|
497
|
+
| `ui:options: { addable, removable }` | gate an array's add / remove buttons |
|
|
498
|
+
|
|
499
|
+
Every `ui:x` may also be written `ui:options: { x: … }` (the two forms are equivalent, as in RJSF). uiSchema strings are untrusted like the schema — they're sanitized the same way, and an unknown `ui:widget` falls back to the default with a `warning` rather than failing.
|
|
500
|
+
|
|
501
|
+
**Custom widgets.** Pass a `widgets` registry — `{ name: (info) => fieldDef }` — and a `ui:widget` of that name renders your field. `info` is `{ name, label, description, required, value, schema }`; return a field definition (a `{ type, … }` object) or a field instance. This is the seam for richer custom controls later.
|
|
502
|
+
|
|
503
|
+
```js
|
|
504
|
+
form.fromSchema(schema, {
|
|
505
|
+
uiSchema: { rating: { 'ui:widget': 'stars' } },
|
|
506
|
+
widgets: {
|
|
507
|
+
stars: ({ name, label }) => ({
|
|
508
|
+
type: 'select',
|
|
509
|
+
name,
|
|
510
|
+
label,
|
|
511
|
+
options: [1, 2, 3, 4, 5].map((n) => ({ label: '★'.repeat(n), value: n }))
|
|
512
|
+
})
|
|
513
|
+
}
|
|
514
|
+
})
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
See [`examples/ui-schema.js`](examples/ui-schema.js). Not yet from uiSchema: `ui:enumDisabled`/`ui:enumOrder`, `ui:emptyValue`, `ui:field`, array `orderable`, markdown, and the web-only `ui:classNames`/`ui:style` (ignored).
|
|
518
|
+
|
|
519
|
+
### Not yet
|
|
520
|
+
|
|
521
|
+
Arrays of primitives or free-form items, nested arrays (an array inside an array's items), `allOf` (schema merge), and `$ref`. Unsupported shapes — including a `oneOf` that mixes object and scalar branches — throw a clear error rather than silently building the wrong form.
|
|
522
|
+
|
|
523
|
+
## License
|
|
524
|
+
|
|
525
|
+
Apache-2.0
|
package/fields/action.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// action — a focusable button (used for array add / remove).
|
|
2
|
+
//
|
|
3
|
+
// It carries no value; instead it holds an `action` descriptor that the form
|
|
4
|
+
// runs when the button is confirmed (enter). The form recognizes `isButton` and
|
|
5
|
+
// routes enter to its own _activate() rather than validating/advancing, so a
|
|
6
|
+
// button never blocks or submits. Reached by tab like any focusable field.
|
|
7
|
+
const { Field } = require('./base')
|
|
8
|
+
|
|
9
|
+
class ActionField extends Field {
|
|
10
|
+
constructor(opts = {}) {
|
|
11
|
+
super(opts)
|
|
12
|
+
this.isButton = true
|
|
13
|
+
this.action = opts.action || null
|
|
14
|
+
this.buttonLabel = opts.buttonLabel || opts.label || 'OK'
|
|
15
|
+
this._focused = false
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
focus() {
|
|
19
|
+
this._focused = true
|
|
20
|
+
return this
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
blur() {
|
|
24
|
+
this._focused = false
|
|
25
|
+
return this
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get focused() {
|
|
29
|
+
return this._focused
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
value() {
|
|
33
|
+
return undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
setValue() {
|
|
37
|
+
return this
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
isEmpty() {
|
|
41
|
+
return true
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
syncValidate() {
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
update() {
|
|
49
|
+
return [this, null] // the form handles enter via _activate
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
view() {
|
|
53
|
+
const t = this.theme
|
|
54
|
+
const text = (this._focused ? '› ' : ' ') + this.buttonLabel
|
|
55
|
+
return this._focused ? t.labelFocused(text) : t.help(text)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function action(opts) {
|
|
60
|
+
return new ActionField(opts)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { action, ActionField }
|