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/form.js
ADDED
|
@@ -0,0 +1,1028 @@
|
|
|
1
|
+
// Form — a declarative form built from fields.
|
|
2
|
+
//
|
|
3
|
+
// Give it an ordered list of fields; it owns focus movement between them (via
|
|
4
|
+
// bare-tui's focus ring), validation, and submission, and renders the whole
|
|
5
|
+
// thing. Fields may be field instances (form.text({…})) or plain definition
|
|
6
|
+
// objects with a `type` (`{ type: 'text', name: 'email' }`) — the latter is the
|
|
7
|
+
// shape the JSON-Schema mapper emits, so both paths converge here.
|
|
8
|
+
//
|
|
9
|
+
// NESTED OBJECTS. A field def may be an object section
|
|
10
|
+
// ({ type: 'object', name, optional, fields: [...] }); the form *flattens* these
|
|
11
|
+
// into a flat, depth-first list of focusable fields — each carrying its `path`
|
|
12
|
+
// (['address','street']) — plus a render `layout` with section headers and
|
|
13
|
+
// indentation. value()/setValues()/errors() walk the paths to produce/consume
|
|
14
|
+
// the nested object. A non-required (`optional`) section gets a checkbox gate.
|
|
15
|
+
//
|
|
16
|
+
// DYNAMIC FIELD SETS (oneOf/anyOf/if-then-else). A field (and its layout item)
|
|
17
|
+
// may carry an `_activeWhen()` predicate — a closure over controlling field
|
|
18
|
+
// instances. Inactive fields are hidden, unfocusable, uncollected, unvalidated.
|
|
19
|
+
// The form recomputes the active set on every keystroke and re-drives the ring.
|
|
20
|
+
//
|
|
21
|
+
// ARRAYS OF OBJECTS ({ type: 'array', itemFields, minItems, maxItems }). The set
|
|
22
|
+
// of fields can change *size* at runtime. Each array tracks an ordered list of
|
|
23
|
+
// entry *ids* (stable identity, independent of position); positions derive paths
|
|
24
|
+
// (contacts.0.name). _build() expands every array's entries and reuses field
|
|
25
|
+
// instances from a cache keyed by (arrayPath, entryId, subPath), so add / remove
|
|
26
|
+
// preserve each entry's values and shift the rest cleanly. Add/remove are
|
|
27
|
+
// focusable buttons (fields/action.js); the form mutates the id list and
|
|
28
|
+
// rebuilds. value() materializes real arrays.
|
|
29
|
+
//
|
|
30
|
+
// Keys (all configurable via the `keys` option — see defaultKeys): enter
|
|
31
|
+
// confirms the focused field and advances / activates a button — it never
|
|
32
|
+
// submits. A dedicated `submit` key (default ctrl+s) commits the form, so a form
|
|
33
|
+
// ending in an array's "+ Add" button can't be submitted by accident. When the
|
|
34
|
+
// body is taller than the terminal the form scrolls, keeping the focused field
|
|
35
|
+
// in view. The form never calls quit; it emits 'form.submit' / 'form.cancel' and
|
|
36
|
+
// the host (or run()) decides what they mean.
|
|
37
|
+
const { Program, quit, batch, key, spinner } = require('bare-tui')
|
|
38
|
+
const focus = require('bare-tui').focus
|
|
39
|
+
|
|
40
|
+
const FRAME_SETS = { dots: spinner.dots, line: spinner.line, points: spinner.points }
|
|
41
|
+
|
|
42
|
+
const { text } = require('./fields/text')
|
|
43
|
+
const { textarea } = require('./fields/textarea')
|
|
44
|
+
const { number } = require('./fields/number')
|
|
45
|
+
const { select } = require('./fields/select')
|
|
46
|
+
const { radio } = require('./fields/radio')
|
|
47
|
+
const { confirm } = require('./fields/confirm')
|
|
48
|
+
const { multiselect } = require('./fields/multiselect')
|
|
49
|
+
const { constant } = require('./fields/constant')
|
|
50
|
+
const { file } = require('./fields/file')
|
|
51
|
+
const { list } = require('./fields/list')
|
|
52
|
+
const { action } = require('./fields/action')
|
|
53
|
+
const { SectionToggleField } = require('./fields/section')
|
|
54
|
+
const { Field } = require('./fields/base')
|
|
55
|
+
const theme = require('./theme')
|
|
56
|
+
|
|
57
|
+
const emit = (msg) => () => msg
|
|
58
|
+
const always = () => true
|
|
59
|
+
|
|
60
|
+
// Form-level keybindings. Every action is an array of chords; pass a partial
|
|
61
|
+
// `keys` to form.create() to override individual ones (merged over these).
|
|
62
|
+
//
|
|
63
|
+
// Submit is deliberately NOT enter: enter confirms a field / activates a button
|
|
64
|
+
// / advances, and a separate `submit` chord commits the form — so a form that
|
|
65
|
+
// ends in an array's "+ Add" button can't be submitted by accident. The default
|
|
66
|
+
// submit is ctrl+s rather than a modifier+enter because most terminals can't
|
|
67
|
+
// tell ctrl/shift+enter from a plain enter (all arrive as \r); bind it to
|
|
68
|
+
// ['alt+enter'] etc. if your terminal supports distinguishing it.
|
|
69
|
+
const defaultKeys = {
|
|
70
|
+
submit: ['ctrl+s'], // commit the whole form
|
|
71
|
+
cancel: ['ctrl+c'], // emit form.cancel
|
|
72
|
+
confirm: ['enter'], // confirm the focused field / activate a button / advance
|
|
73
|
+
next: ['tab'], // focus the next field
|
|
74
|
+
prev: ['shift+tab'], // focus the previous field
|
|
75
|
+
scrollUp: ['pageup'], // scroll the body up a page (when it doesn't all fit)
|
|
76
|
+
scrollDown: ['pagedown'] // scroll the body down a page
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function mergeKeys(user) {
|
|
80
|
+
const out = {}
|
|
81
|
+
for (const k of Object.keys(defaultKeys)) {
|
|
82
|
+
out[k] = user && user[k] ? [].concat(user[k]) : defaultKeys[k].slice()
|
|
83
|
+
}
|
|
84
|
+
return out
|
|
85
|
+
}
|
|
86
|
+
const SEP = '\u0000' // path/cache-key segment separator: a NUL, which never appears in a JSON key
|
|
87
|
+
|
|
88
|
+
const builders = {
|
|
89
|
+
text,
|
|
90
|
+
textarea,
|
|
91
|
+
number,
|
|
92
|
+
select,
|
|
93
|
+
radio,
|
|
94
|
+
confirm,
|
|
95
|
+
multiselect,
|
|
96
|
+
constant,
|
|
97
|
+
file,
|
|
98
|
+
list
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Turn a leaf field definition object into a field instance; pass instances
|
|
102
|
+
// through untouched. Object/variant/conditional/array groups are handled by
|
|
103
|
+
// flatten(), not here.
|
|
104
|
+
function fromDef(def) {
|
|
105
|
+
if (def instanceof Field) return def
|
|
106
|
+
if (def && typeof def === 'object' && def.type) {
|
|
107
|
+
const build = builders[def.type]
|
|
108
|
+
if (!build) throw new Error('unknown field type: ' + def.type)
|
|
109
|
+
return build(def)
|
|
110
|
+
}
|
|
111
|
+
throw new Error('form field must be a field instance or a { type, … } object')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Fetch-or-create a field instance by cache key, so rebuilds preserve state.
|
|
115
|
+
function getOrCreate(ctx, keySegs, factory) {
|
|
116
|
+
const k = keySegs.join(SEP)
|
|
117
|
+
let f = ctx.cache.get(k)
|
|
118
|
+
if (!f) {
|
|
119
|
+
f = factory()
|
|
120
|
+
ctx.cache.set(k, f)
|
|
121
|
+
}
|
|
122
|
+
return f
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Read `path` from `obj`. Returns { found, value } so an explicit `undefined`
|
|
126
|
+
// is distinguishable from "absent" (uses hasOwnProperty — pollution-safe; works
|
|
127
|
+
// on arrays since numeric string indices are own keys).
|
|
128
|
+
function getIn(obj, path) {
|
|
129
|
+
let node = obj
|
|
130
|
+
for (let i = 0; i < path.length; i++) {
|
|
131
|
+
if (!node || typeof node !== 'object') return { found: false }
|
|
132
|
+
const k = path[i]
|
|
133
|
+
if (!Object.prototype.hasOwnProperty.call(node, k)) return { found: false }
|
|
134
|
+
node = node[k]
|
|
135
|
+
}
|
|
136
|
+
return { found: true, value: node }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Is `prefix` a (strict or equal) ancestor path of `path`?
|
|
140
|
+
function isAncestor(prefix, path) {
|
|
141
|
+
if (prefix.length >= path.length) return false
|
|
142
|
+
for (let i = 0; i < prefix.length; i++) if (prefix[i] !== path[i]) return false
|
|
143
|
+
return true
|
|
144
|
+
}
|
|
145
|
+
function startsWith(path, prefix) {
|
|
146
|
+
if (path.length < prefix.length) return false
|
|
147
|
+
for (let i = 0; i < prefix.length; i++) if (path[i] !== prefix[i]) return false
|
|
148
|
+
return true
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Flatten (possibly nested / dynamic / repeating) field defs into { fields,
|
|
152
|
+
// layout }. `parentPath` is the value path; `parentCache` is the parallel cache-
|
|
153
|
+
// key prefix (identical to parentPath except inside an array entry, where the
|
|
154
|
+
// position index in the path is an entry id in the cache — so values follow an
|
|
155
|
+
// entry across reorders). `active` is the inherited activeness predicate. `ctx`
|
|
156
|
+
// carries the instance cache, the array-state registry, and the set of array
|
|
157
|
+
// paths (for array-aware value()).
|
|
158
|
+
function flatten(defs, parentPath, parentCache, active, ctx) {
|
|
159
|
+
const fields = []
|
|
160
|
+
const layout = []
|
|
161
|
+
const locals = {} // local name → field, for conditional guards
|
|
162
|
+
|
|
163
|
+
const add = (field, activeWhen, indent) => {
|
|
164
|
+
field._activeWhen = activeWhen
|
|
165
|
+
fields.push(field)
|
|
166
|
+
layout.push({ type: 'field', field, indent, activeWhen })
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const def of defs) {
|
|
170
|
+
// --- nested object: subform or optional gate -----------------------------
|
|
171
|
+
if (def && !(def instanceof Field) && def.type === 'object') {
|
|
172
|
+
const path = parentPath.concat(def.name)
|
|
173
|
+
const cache = parentCache.concat(def.name)
|
|
174
|
+
const indent = path.length - 1
|
|
175
|
+
if (def.optional) {
|
|
176
|
+
const toggle = getOrCreate(
|
|
177
|
+
ctx,
|
|
178
|
+
cache,
|
|
179
|
+
() =>
|
|
180
|
+
new SectionToggleField({
|
|
181
|
+
name: def.name,
|
|
182
|
+
title: def.title || def.name,
|
|
183
|
+
description: def.description || '',
|
|
184
|
+
value: !!def.value
|
|
185
|
+
})
|
|
186
|
+
)
|
|
187
|
+
toggle.path = path
|
|
188
|
+
locals[def.name] = toggle
|
|
189
|
+
add(toggle, active, indent)
|
|
190
|
+
} else {
|
|
191
|
+
layout.push({
|
|
192
|
+
type: 'header',
|
|
193
|
+
title: def.title || def.name,
|
|
194
|
+
description: def.description || '',
|
|
195
|
+
indent,
|
|
196
|
+
activeWhen: active
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
const child = flatten(def.fields || [], path, cache, active, ctx)
|
|
200
|
+
fields.push(...child.fields)
|
|
201
|
+
layout.push(...child.layout)
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// --- oneOf/anyOf variant -------------------------------------------------
|
|
206
|
+
if (def && def.type === 'variant') {
|
|
207
|
+
const path = parentPath.concat(def.name)
|
|
208
|
+
const cache = parentCache.concat(def.name)
|
|
209
|
+
const selector = getOrCreate(ctx, cache.concat('$select'), () =>
|
|
210
|
+
select({
|
|
211
|
+
name: def.name,
|
|
212
|
+
label: def.selectorLabel || def.title || def.name,
|
|
213
|
+
description: def.description || '',
|
|
214
|
+
options: def.branches.map((b, i) => ({ label: b.title || `Option ${i + 1}`, value: i })),
|
|
215
|
+
selected: 0
|
|
216
|
+
})
|
|
217
|
+
)
|
|
218
|
+
selector.path = path
|
|
219
|
+
selector.isSelector = true
|
|
220
|
+
selector._branchKeys = def.branches.map((b) =>
|
|
221
|
+
(b.fields || []).map((x) => x && x.name).filter(Boolean)
|
|
222
|
+
)
|
|
223
|
+
locals[def.name] = selector
|
|
224
|
+
add(selector, active, path.length - 1)
|
|
225
|
+
|
|
226
|
+
def.branches.forEach((branch, i) => {
|
|
227
|
+
const branchActive = () => active() && selector.value() === i
|
|
228
|
+
// Branches share the value path but are mutually exclusive, so give each
|
|
229
|
+
// its own cache prefix — otherwise two branches' same-named fields would
|
|
230
|
+
// collide on one cached instance.
|
|
231
|
+
const child = flatten(branch.fields || [], path, cache.concat('~' + i), branchActive, ctx)
|
|
232
|
+
fields.push(...child.fields)
|
|
233
|
+
layout.push(...child.layout)
|
|
234
|
+
})
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// --- if/then/else conditional --------------------------------------------
|
|
239
|
+
if (def && def.type === 'conditional') {
|
|
240
|
+
const controllers = (def.when || []).map((w) => ({ name: w.name, allowed: w.allowed }))
|
|
241
|
+
const guard = () =>
|
|
242
|
+
controllers.every((c) => {
|
|
243
|
+
const f = locals[c.name]
|
|
244
|
+
return f ? c.allowed.has(f.value()) : false
|
|
245
|
+
})
|
|
246
|
+
const thenActive = () => active() && guard()
|
|
247
|
+
const elseActive = () => active() && !guard()
|
|
248
|
+
// Distinct cache prefixes so a then field and an else field of the same
|
|
249
|
+
// name don't share one cached instance (they're mutually exclusive).
|
|
250
|
+
const t = flatten(def.then || [], parentPath, parentCache.concat('~then'), thenActive, ctx)
|
|
251
|
+
const e = flatten(def.else || [], parentPath, parentCache.concat('~else'), elseActive, ctx)
|
|
252
|
+
fields.push(...t.fields, ...e.fields)
|
|
253
|
+
layout.push(...t.layout, ...e.layout)
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// --- array of objects ----------------------------------------------------
|
|
258
|
+
if (def && def.type === 'array') {
|
|
259
|
+
const path = parentPath.concat(def.name)
|
|
260
|
+
const cache = parentCache.concat(def.name)
|
|
261
|
+
const stateKey = path.join(SEP)
|
|
262
|
+
ctx.arrayPaths.set(stateKey, path)
|
|
263
|
+
let state = ctx.arrays.get(stateKey)
|
|
264
|
+
if (!state) {
|
|
265
|
+
state = { ids: [], nextId: 1, minItems: def.minItems || 0, maxItems: def.maxItems || 0 }
|
|
266
|
+
for (let i = 0; i < state.minItems; i++) state.ids.push(state.nextId++)
|
|
267
|
+
ctx.arrays.set(stateKey, state)
|
|
268
|
+
}
|
|
269
|
+
state.path = path
|
|
270
|
+
state.cache = cache
|
|
271
|
+
state.activeWhen = active
|
|
272
|
+
const addable = def.addable !== false // uiSchema ui:options.addable
|
|
273
|
+
const removable = def.removable !== false // uiSchema ui:options.removable
|
|
274
|
+
|
|
275
|
+
layout.push({
|
|
276
|
+
type: 'arrayHeader',
|
|
277
|
+
title: def.title || def.name,
|
|
278
|
+
description: def.description || '',
|
|
279
|
+
indent: path.length - 1,
|
|
280
|
+
arrayKey: stateKey,
|
|
281
|
+
activeWhen: active
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
state.ids.forEach((id, pos) => {
|
|
285
|
+
const entryPath = path.concat(String(pos))
|
|
286
|
+
const entryCache = cache.concat('@' + id)
|
|
287
|
+
layout.push({
|
|
288
|
+
type: 'entryHeader',
|
|
289
|
+
title: def.itemTitle || 'Item',
|
|
290
|
+
index: pos,
|
|
291
|
+
indent: path.length,
|
|
292
|
+
activeWhen: active
|
|
293
|
+
})
|
|
294
|
+
const child = flatten(def.itemFields || [], entryPath, entryCache, active, ctx)
|
|
295
|
+
fields.push(...child.fields)
|
|
296
|
+
layout.push(...child.layout)
|
|
297
|
+
if (removable && state.ids.length > state.minItems) {
|
|
298
|
+
const btn = getOrCreate(ctx, entryCache.concat('$remove'), () =>
|
|
299
|
+
action({
|
|
300
|
+
name: '$remove',
|
|
301
|
+
buttonLabel: '✕ Remove',
|
|
302
|
+
action: { kind: 'array.remove', arrayKey: stateKey, id }
|
|
303
|
+
})
|
|
304
|
+
)
|
|
305
|
+
btn.path = entryPath.concat('$remove')
|
|
306
|
+
add(btn, active, path.length)
|
|
307
|
+
}
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
if (addable && state.ids.length < state.maxItems) {
|
|
311
|
+
const btn = getOrCreate(ctx, cache.concat('$add'), () =>
|
|
312
|
+
action({
|
|
313
|
+
name: '$add',
|
|
314
|
+
buttonLabel: def.addLabel || '+ Add',
|
|
315
|
+
action: { kind: 'array.add', arrayKey: stateKey }
|
|
316
|
+
})
|
|
317
|
+
)
|
|
318
|
+
btn.path = path.concat('$add')
|
|
319
|
+
add(btn, active, path.length - 1)
|
|
320
|
+
}
|
|
321
|
+
continue
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// --- leaf ----------------------------------------------------------------
|
|
325
|
+
const key = def instanceof Field ? def.key : def.name
|
|
326
|
+
const field = getOrCreate(ctx, parentCache.concat(key), () => fromDef(def))
|
|
327
|
+
field.path = parentPath.concat(field.key)
|
|
328
|
+
if (field.key) locals[field.key] = field
|
|
329
|
+
field._activeWhen = active
|
|
330
|
+
fields.push(field)
|
|
331
|
+
// A hidden field (uiSchema `ui:widget: 'hidden'`) is collected but never
|
|
332
|
+
// rendered or focused.
|
|
333
|
+
if (!field.hidden) {
|
|
334
|
+
layout.push({ type: 'field', field, indent: field.path.length - 1, activeWhen: active })
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return { fields, layout }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
class Form {
|
|
342
|
+
constructor(opts = {}) {
|
|
343
|
+
this.title = opts.title || ''
|
|
344
|
+
this.description = opts.description || ''
|
|
345
|
+
this.theme = theme.merge(opts.theme)
|
|
346
|
+
this.keys = mergeKeys(opts.keys)
|
|
347
|
+
// Re-run dirty async validators on submit (serially, behind one spinner).
|
|
348
|
+
// Default on; pass false to opt out — then YOU are responsible for ensuring
|
|
349
|
+
// async checks ran (the form will submit whatever's there).
|
|
350
|
+
this.validateAsyncOnSubmit = opts.validateAsyncOnSubmit !== false
|
|
351
|
+
this._formValidating = false // true while the submit-time async pass runs
|
|
352
|
+
this._formSpinner = null
|
|
353
|
+
this._queue = [] // async fields still to check this submit
|
|
354
|
+
this._queueAt = 0
|
|
355
|
+
this._submitToken = 0 // generation: discards stale/cancelled check results
|
|
356
|
+
this._defs = opts.fields || []
|
|
357
|
+
this._cache = new Map()
|
|
358
|
+
this._arrays = new Map()
|
|
359
|
+
this._arrayErrors = {}
|
|
360
|
+
this.width = 0
|
|
361
|
+
this.height = 0 // 0 = unknown → render everything inline (no scrolling)
|
|
362
|
+
this._scroll = 0 // body scroll offset (lines), used once height is known
|
|
363
|
+
this.ring = focus.create({
|
|
364
|
+
items: [],
|
|
365
|
+
keys: {
|
|
366
|
+
next: key.binding({ keys: this.keys.next }),
|
|
367
|
+
prev: key.binding({ keys: this.keys.prev })
|
|
368
|
+
}
|
|
369
|
+
})
|
|
370
|
+
this.submitted = false
|
|
371
|
+
this.cancelled = false
|
|
372
|
+
this.busy = false // true while an async validation is in flight
|
|
373
|
+
this._build()
|
|
374
|
+
this._recompute()
|
|
375
|
+
this._applyAutofocus()
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// uiSchema `ui:autofocus`: start focus on the first field that asked for it.
|
|
379
|
+
_applyAutofocus() {
|
|
380
|
+
const i = this.ring.items.findIndex((f) => f.autofocus)
|
|
381
|
+
if (i >= 0) this.ring.focus(i)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// (Re)expand defs into the current flat field list + render layout, reusing
|
|
385
|
+
// cached instances so values survive structural changes (array add/remove).
|
|
386
|
+
_build() {
|
|
387
|
+
const ctx = { cache: this._cache, arrays: this._arrays, arrayPaths: new Map() }
|
|
388
|
+
const { fields, layout } = flatten(this._defs, [], [], always, ctx)
|
|
389
|
+
this.fields = fields
|
|
390
|
+
this.layout = layout
|
|
391
|
+
this._arrayPaths = ctx.arrayPaths
|
|
392
|
+
this._arrayKeySet = new Set(ctx.arrayPaths.keys())
|
|
393
|
+
this.fields.forEach((f) => f.setTheme(this.theme))
|
|
394
|
+
return this
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
init() {
|
|
398
|
+
const cmds = this.fields
|
|
399
|
+
.map((f) => (typeof f.init === 'function' ? f.init() : null))
|
|
400
|
+
.filter(Boolean)
|
|
401
|
+
return cmds.length ? batch(...cmds) : null
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
_isActive(field) {
|
|
405
|
+
return field._activeWhen ? !!field._activeWhen() : true
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Recompute the live fields and re-drive the focus ring with the active,
|
|
409
|
+
// focusable subset — preserving focus on the current field when it survives.
|
|
410
|
+
_recompute() {
|
|
411
|
+
const focused = this.ring.focused()
|
|
412
|
+
this.activeFields = this.fields.filter((f) => this._isActive(f))
|
|
413
|
+
const items = this.activeFields.filter((f) => f.focusable !== false)
|
|
414
|
+
this.ring.setItems(items)
|
|
415
|
+
if (focused) {
|
|
416
|
+
const i = items.indexOf(focused)
|
|
417
|
+
if (i >= 0) this.ring.focus(i)
|
|
418
|
+
}
|
|
419
|
+
return this
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Build `value` at `path`, creating arrays at array paths and objects elsewhere.
|
|
423
|
+
_setIn(obj, path, value) {
|
|
424
|
+
let node = obj
|
|
425
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
426
|
+
const k = path[i]
|
|
427
|
+
if (node[k] === null || node[k] === undefined || typeof node[k] !== 'object') {
|
|
428
|
+
node[k] = this._arrayKeySet.has(path.slice(0, i + 1).join(SEP)) ? [] : {}
|
|
429
|
+
}
|
|
430
|
+
node = node[k]
|
|
431
|
+
}
|
|
432
|
+
node[path[path.length - 1]] = value
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// The collected values, as a (possibly nested, possibly array-bearing) object.
|
|
436
|
+
// Selectors, section gates, and buttons contribute no value; inactive fields
|
|
437
|
+
// and the subtree of an unchecked optional section are omitted. Active arrays
|
|
438
|
+
// always appear (as [] when empty).
|
|
439
|
+
value() {
|
|
440
|
+
const out = {}
|
|
441
|
+
for (const f of this.activeFields) {
|
|
442
|
+
if (f.isSection || f.isSelector || f.isButton) continue
|
|
443
|
+
if (this._gatedOff(f)) continue
|
|
444
|
+
this._setIn(out, f.path, f.value())
|
|
445
|
+
}
|
|
446
|
+
for (const state of this._arrays.values()) {
|
|
447
|
+
if (state.activeWhen && !state.activeWhen()) continue
|
|
448
|
+
if (!getIn(out, state.path).found) this._setIn(out, state.path, [])
|
|
449
|
+
}
|
|
450
|
+
return out
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Rehydrate from an existing object: size arrays to match the data (then
|
|
454
|
+
// rebuild), point variant selectors at the matching branch, arm present
|
|
455
|
+
// optional sections, and set every leaf by path.
|
|
456
|
+
setValues(values) {
|
|
457
|
+
if (!values || typeof values !== 'object') return this
|
|
458
|
+
let resized = false
|
|
459
|
+
for (const state of this._arrays.values()) {
|
|
460
|
+
const res = getIn(values, state.path)
|
|
461
|
+
if (!res.found || !Array.isArray(res.value)) continue
|
|
462
|
+
const target = Math.max(Math.min(res.value.length, state.maxItems), state.minItems)
|
|
463
|
+
if (state.ids.length === target) continue
|
|
464
|
+
while (state.ids.length > target) this._evictEntry(state, state.ids.pop())
|
|
465
|
+
while (state.ids.length < target) state.ids.push(state.nextId++)
|
|
466
|
+
resized = true
|
|
467
|
+
}
|
|
468
|
+
if (resized) this._build()
|
|
469
|
+
|
|
470
|
+
for (const f of this.fields) {
|
|
471
|
+
if (f.isButton) continue
|
|
472
|
+
const res = getIn(values, f.path)
|
|
473
|
+
if (f.isSelector) {
|
|
474
|
+
this._pickBranch(f, res)
|
|
475
|
+
continue
|
|
476
|
+
}
|
|
477
|
+
if (f.isSection) {
|
|
478
|
+
f.setValue(res.found && res.value !== null && res.value !== undefined)
|
|
479
|
+
continue
|
|
480
|
+
}
|
|
481
|
+
if (!res.found || res.value === undefined) continue
|
|
482
|
+
if (typeof f.setValue === 'function') f.setValue(res.value)
|
|
483
|
+
f.error = null
|
|
484
|
+
}
|
|
485
|
+
return this._recompute()
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
_pickBranch(selector, res) {
|
|
489
|
+
if (!res.found || !res.value || typeof res.value !== 'object') return
|
|
490
|
+
const obj = res.value
|
|
491
|
+
let best = -1
|
|
492
|
+
let bestScore = 0
|
|
493
|
+
selector._branchKeys.forEach((keys, i) => {
|
|
494
|
+
const score = keys.reduce(
|
|
495
|
+
(n, k) => n + (Object.prototype.hasOwnProperty.call(obj, k) ? 1 : 0),
|
|
496
|
+
0
|
|
497
|
+
)
|
|
498
|
+
if (score > bestScore) {
|
|
499
|
+
bestScore = score
|
|
500
|
+
best = i
|
|
501
|
+
}
|
|
502
|
+
})
|
|
503
|
+
if (best >= 0) selector.setValue(best)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Current errors, keyed by dotted path (live fields + array-level errors).
|
|
507
|
+
errors() {
|
|
508
|
+
const out = {}
|
|
509
|
+
for (const f of this.activeFields) if (f.error) out[f.path.join('.')] = f.error
|
|
510
|
+
for (const k of Object.keys(this._arrayErrors)) out[k] = this._arrayErrors[k]
|
|
511
|
+
return out
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
_gatedOff(field) {
|
|
515
|
+
for (const f of this.fields) {
|
|
516
|
+
if (f.isSection && !f.checked && isAncestor(f.path, field.path)) return true
|
|
517
|
+
}
|
|
518
|
+
return false
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
_autoCheckSection() {
|
|
522
|
+
const cur = this.ring.focused()
|
|
523
|
+
if (!cur || cur.isSection || cur.isButton || cur.isEmpty(cur.value())) return
|
|
524
|
+
for (const f of this.fields) {
|
|
525
|
+
if (f.isSection && !f.checked && isAncestor(f.path, cur.path)) f.setValue(true)
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Drop a removed entry's cached field instances so its values don't linger.
|
|
530
|
+
_evictEntry(state, id) {
|
|
531
|
+
const prefix = state.cache.concat('@' + id).join(SEP)
|
|
532
|
+
for (const k of this._cache.keys()) {
|
|
533
|
+
if (k === prefix || k.startsWith(prefix + SEP)) this._cache.delete(k)
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
_k(name, msg) {
|
|
538
|
+
return key.matches(msg, ...this.keys[name])
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
update(msg) {
|
|
542
|
+
if (msg && msg.type === 'form.validated') return this._onValidated(msg)
|
|
543
|
+
if (msg && msg.type === 'form.checkResult') return this._onCheckResult(msg)
|
|
544
|
+
|
|
545
|
+
// The terminal size: switches on scrolling and keeps focus visible.
|
|
546
|
+
if (msg && msg.type === 'resize') {
|
|
547
|
+
this.width = msg.width
|
|
548
|
+
this.height = msg.height
|
|
549
|
+
this._scrollToFocused()
|
|
550
|
+
return [this, null]
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (msg && msg.type === 'spinner.tick') {
|
|
554
|
+
// The submit-time form spinner takes precedence over a field spinner;
|
|
555
|
+
// they never run at once.
|
|
556
|
+
if (this._formValidating && this._formSpinner) {
|
|
557
|
+
const [, cmd] = this._formSpinner.update(msg)
|
|
558
|
+
return [this, this._formValidating ? cmd : null]
|
|
559
|
+
}
|
|
560
|
+
const f = this._validatingField()
|
|
561
|
+
if (f && f.spinner) {
|
|
562
|
+
const [, cmd] = f.spinner.update(msg)
|
|
563
|
+
return [this, f.validating ? cmd : null]
|
|
564
|
+
}
|
|
565
|
+
return [this, null]
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (msg && msg.type === 'key') {
|
|
569
|
+
// Cancel works even mid-validation; everything else waits.
|
|
570
|
+
if (this._k('cancel', msg)) {
|
|
571
|
+
this._cancelValidation()
|
|
572
|
+
this.cancelled = true
|
|
573
|
+
return [this, emit({ type: 'form.cancel' })]
|
|
574
|
+
}
|
|
575
|
+
if (this.busy) return [this, null]
|
|
576
|
+
|
|
577
|
+
// Deliberate submit (never plain enter — see defaultKeys).
|
|
578
|
+
if (this._k('submit', msg)) return this.submit()
|
|
579
|
+
|
|
580
|
+
// Manual scrolling when the body is taller than the screen.
|
|
581
|
+
if (this._k('scrollUp', msg)) return this._scrollBy(-this._bodyHeight())
|
|
582
|
+
if (this._k('scrollDown', msg)) return this._scrollBy(this._bodyHeight())
|
|
583
|
+
|
|
584
|
+
const cur = this.ring.focused()
|
|
585
|
+
// Enter confirms the focused field / activates a button / advances — it
|
|
586
|
+
// does NOT submit. (A control that wants enter itself keeps it.)
|
|
587
|
+
if (this._k('confirm', msg) && (!cur || !cur.wantsEnter())) {
|
|
588
|
+
return this._confirm()
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (this.busy) return [this, null]
|
|
593
|
+
|
|
594
|
+
const [ring, cmd] = this.ring.update(msg)
|
|
595
|
+
this.ring = ring
|
|
596
|
+
if (msg && msg.type === 'key') {
|
|
597
|
+
if (!this._k('next', msg) && !this._k('prev', msg)) this._autoCheckSection()
|
|
598
|
+
this._recompute() // a selector/controller may have changed
|
|
599
|
+
this._scrollToFocused() // keep the (possibly moved) focus in view
|
|
600
|
+
}
|
|
601
|
+
return [this, cmd]
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
_confirm() {
|
|
605
|
+
const cur = this.ring.focused()
|
|
606
|
+
if (!cur) return this._advance()
|
|
607
|
+
if (cur.isButton) return this._activate(cur)
|
|
608
|
+
if (cur.isSection || this._gatedOff(cur)) {
|
|
609
|
+
cur.error = null
|
|
610
|
+
return this._advance()
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const v = cur.value()
|
|
614
|
+
const reqErr = cur.requiredError(v)
|
|
615
|
+
if (reqErr) {
|
|
616
|
+
cur.error = reqErr
|
|
617
|
+
return [this, null]
|
|
618
|
+
}
|
|
619
|
+
if (cur.isEmpty(v)) {
|
|
620
|
+
cur.error = null
|
|
621
|
+
return this._advance()
|
|
622
|
+
}
|
|
623
|
+
if (cur.isAsync()) {
|
|
624
|
+
return this._beginAsync(cur, cur.runValidator(v))
|
|
625
|
+
}
|
|
626
|
+
cur.error = cur.syncValidate(v)
|
|
627
|
+
if (cur.error) return [this, null]
|
|
628
|
+
return this._advance()
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Run an array add/remove button, then rebuild and refocus sensibly.
|
|
632
|
+
_activate(button) {
|
|
633
|
+
const a = button.action
|
|
634
|
+
if (!a) return [this, null]
|
|
635
|
+
const state = this._arrays.get(a.arrayKey)
|
|
636
|
+
if (!state) return [this, null]
|
|
637
|
+
|
|
638
|
+
if (a.kind === 'array.add') {
|
|
639
|
+
if (state.ids.length >= state.maxItems) return [this, null]
|
|
640
|
+
state.ids.push(state.nextId++)
|
|
641
|
+
this._build()
|
|
642
|
+
this._recompute()
|
|
643
|
+
this._focusPrefix(state.path.concat(String(state.ids.length - 1)))
|
|
644
|
+
} else if (a.kind === 'array.remove') {
|
|
645
|
+
const idx = state.ids.indexOf(a.id)
|
|
646
|
+
if (idx >= 0) {
|
|
647
|
+
this._evictEntry(state, a.id)
|
|
648
|
+
state.ids.splice(idx, 1)
|
|
649
|
+
}
|
|
650
|
+
this._build()
|
|
651
|
+
this._recompute()
|
|
652
|
+
this._focusAddOrPrefix(state)
|
|
653
|
+
}
|
|
654
|
+
this._scrollToFocused()
|
|
655
|
+
return [this, null]
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
_focusPrefix(prefix) {
|
|
659
|
+
const i = this.ring.items.findIndex((f) => startsWith(f.path, prefix))
|
|
660
|
+
if (i >= 0) this.ring.focus(i)
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
_focusAddOrPrefix(state) {
|
|
664
|
+
const add = this.ring.items.findIndex(
|
|
665
|
+
(f) =>
|
|
666
|
+
f.isButton &&
|
|
667
|
+
f.action &&
|
|
668
|
+
f.action.kind === 'array.add' &&
|
|
669
|
+
f.action.arrayKey === state.path.join(SEP)
|
|
670
|
+
)
|
|
671
|
+
if (add >= 0) this.ring.focus(add)
|
|
672
|
+
else this._focusPrefix(state.path)
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
_beginAsync(field, promise) {
|
|
676
|
+
field.error = null
|
|
677
|
+
field.validating = true
|
|
678
|
+
this.busy = true
|
|
679
|
+
const id = ++field._runId
|
|
680
|
+
const fieldKey = field.key
|
|
681
|
+
|
|
682
|
+
const tickCmd = field.startSpinner()
|
|
683
|
+
const checkCmd = () =>
|
|
684
|
+
Promise.resolve()
|
|
685
|
+
.then(() => promise)
|
|
686
|
+
.then((err) => ({ type: 'form.validated', id, key: fieldKey, error: err || null }))
|
|
687
|
+
.catch((e) => ({
|
|
688
|
+
type: 'form.validated',
|
|
689
|
+
id,
|
|
690
|
+
key: fieldKey,
|
|
691
|
+
error: (e && e.message) || 'check failed'
|
|
692
|
+
}))
|
|
693
|
+
|
|
694
|
+
return [this, batch(tickCmd, checkCmd)]
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
_onValidated(msg) {
|
|
698
|
+
const field = this.fields.find((f) => f.key === msg.key)
|
|
699
|
+
if (!field || !field.validating || msg.id !== field._runId) return [this, null]
|
|
700
|
+
|
|
701
|
+
field.validating = false
|
|
702
|
+
field.stopSpinner()
|
|
703
|
+
field.error = msg.error || null
|
|
704
|
+
this.busy = false
|
|
705
|
+
|
|
706
|
+
if (field.error) return [this, null]
|
|
707
|
+
field._asyncCheckedValue = field.value() // remember it passed; submit won't redo it
|
|
708
|
+
return this._advance()
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
_cancelValidation() {
|
|
712
|
+
const field = this._validatingField()
|
|
713
|
+
if (field) {
|
|
714
|
+
field._runId++
|
|
715
|
+
field.validating = false
|
|
716
|
+
field.stopSpinner()
|
|
717
|
+
}
|
|
718
|
+
if (this._formValidating) {
|
|
719
|
+
this._submitToken++ // strand any in-flight submit check
|
|
720
|
+
this._endFormValidation()
|
|
721
|
+
}
|
|
722
|
+
this.busy = false
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
_validatingField() {
|
|
726
|
+
return this.fields.find((f) => f.validating) || null
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Move focus to the next field (never submits — submit is its own key). Stays
|
|
730
|
+
// put on the last field; the user reaches buttons with `next`/tab and commits
|
|
731
|
+
// with the `submit` key.
|
|
732
|
+
_advance() {
|
|
733
|
+
const next = this.ring.index + 1
|
|
734
|
+
if (next < this.ring.items.length) this.ring.focus(next)
|
|
735
|
+
this._scrollToFocused()
|
|
736
|
+
return [this, null]
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Validate every live, focusable, non-button field plus array min-item counts;
|
|
740
|
+
// jump to the first error, else complete and emit form.submit with the values.
|
|
741
|
+
submit() {
|
|
742
|
+
this._arrayErrors = {}
|
|
743
|
+
let firstErr = null
|
|
744
|
+
for (const f of this.activeFields) {
|
|
745
|
+
if (f.isSection || f.isSelector || f.isButton || f.focusable === false || this._gatedOff(f)) {
|
|
746
|
+
f.error = null
|
|
747
|
+
continue
|
|
748
|
+
}
|
|
749
|
+
const e = f.syncValidate(f.value())
|
|
750
|
+
f.error = e
|
|
751
|
+
if (e && !firstErr) firstErr = f
|
|
752
|
+
}
|
|
753
|
+
if (firstErr) {
|
|
754
|
+
const i = this.ring.items.indexOf(firstErr)
|
|
755
|
+
if (i >= 0) this.ring.focus(i)
|
|
756
|
+
this._scrollToFocused()
|
|
757
|
+
return [this, null]
|
|
758
|
+
}
|
|
759
|
+
// Array minItems: only for live arrays.
|
|
760
|
+
for (const state of this._arrays.values()) {
|
|
761
|
+
if (state.activeWhen && !state.activeWhen()) continue
|
|
762
|
+
if (state.ids.length < state.minItems) {
|
|
763
|
+
const n = state.minItems
|
|
764
|
+
this._arrayErrors[state.path.join('.')] = `add at least ${n} item${n === 1 ? '' : 's'}`
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
if (Object.keys(this._arrayErrors).length) return [this, null]
|
|
768
|
+
|
|
769
|
+
// Async pass: re-run any dirty async validators that never ran (or whose
|
|
770
|
+
// value changed since). Serial, behind one form spinner — unless opted out.
|
|
771
|
+
if (this.validateAsyncOnSubmit) {
|
|
772
|
+
const queue = this.activeFields.filter(
|
|
773
|
+
(f) =>
|
|
774
|
+
!f.isSection &&
|
|
775
|
+
!f.isSelector &&
|
|
776
|
+
!f.isButton &&
|
|
777
|
+
f.focusable !== false &&
|
|
778
|
+
!this._gatedOff(f) &&
|
|
779
|
+
f.needsAsyncCheck()
|
|
780
|
+
)
|
|
781
|
+
if (queue.length) return this._beginFormValidation(queue)
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
this.submitted = true
|
|
785
|
+
return [this, emit({ type: 'form.submit', values: this.value() })]
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Build the form-level validating spinner from the theme (mirrors a field's).
|
|
789
|
+
_makeSpinner() {
|
|
790
|
+
const cfg = (this.theme && this.theme.spinner) || {}
|
|
791
|
+
const frames = typeof cfg.frames === 'string' ? FRAME_SETS[cfg.frames] : cfg.frames
|
|
792
|
+
return spinner.create({ frames, fps: cfg.fps })
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Enter the submit-time async pass: gate input, show the form spinner, and
|
|
796
|
+
// kick off the first check. Each check resolves to a 'form.checkResult' Msg.
|
|
797
|
+
_beginFormValidation(queue) {
|
|
798
|
+
this._formValidating = true
|
|
799
|
+
this.busy = true
|
|
800
|
+
this._queue = queue
|
|
801
|
+
this._queueAt = 0
|
|
802
|
+
this._submitToken++
|
|
803
|
+
this._formSpinner = this._makeSpinner()
|
|
804
|
+
return [this, batch(this._formSpinner.init(), this._checkCmd(0))]
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// A Cmd that runs queue[i]'s async validator and reports the verdict. The
|
|
808
|
+
// generation token is captured at issue time so a cancel makes it stale.
|
|
809
|
+
_checkCmd(i) {
|
|
810
|
+
const field = this._queue[i]
|
|
811
|
+
const token = this._submitToken
|
|
812
|
+
const value = field.value()
|
|
813
|
+
const result = field.runValidator(value)
|
|
814
|
+
return () =>
|
|
815
|
+
Promise.resolve()
|
|
816
|
+
.then(() => result)
|
|
817
|
+
.then((err) => ({ type: 'form.checkResult', token, index: i, value, error: err || null }))
|
|
818
|
+
.catch((e) => ({
|
|
819
|
+
type: 'form.checkResult',
|
|
820
|
+
token,
|
|
821
|
+
index: i,
|
|
822
|
+
value,
|
|
823
|
+
error: (e && e.message) || 'check failed'
|
|
824
|
+
}))
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
_onCheckResult(msg) {
|
|
828
|
+
// Drop results from a cancelled or superseded submit.
|
|
829
|
+
if (!this._formValidating || msg.token !== this._submitToken) return [this, null]
|
|
830
|
+
const field = this._queue[msg.index]
|
|
831
|
+
|
|
832
|
+
if (msg.error) {
|
|
833
|
+
field.error = msg.error
|
|
834
|
+
this._endFormValidation()
|
|
835
|
+
const ri = this.ring.items.indexOf(field)
|
|
836
|
+
if (ri >= 0) this.ring.focus(ri)
|
|
837
|
+
this._scrollToFocused()
|
|
838
|
+
return [this, null]
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
field._asyncCheckedValue = msg.value // passed for this value
|
|
842
|
+
field.error = null
|
|
843
|
+
const next = msg.index + 1
|
|
844
|
+
if (next < this._queue.length) {
|
|
845
|
+
this._queueAt = next
|
|
846
|
+
return [this, this._checkCmd(next)] // spinner keeps ticking on its own
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// All clear → submit.
|
|
850
|
+
this._endFormValidation()
|
|
851
|
+
this.submitted = true
|
|
852
|
+
return [this, emit({ type: 'form.submit', values: this.value() })]
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
_endFormValidation() {
|
|
856
|
+
this._formValidating = false
|
|
857
|
+
this.busy = false
|
|
858
|
+
this._formSpinner = null
|
|
859
|
+
this._queue = []
|
|
860
|
+
this._queueAt = 0
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// The fixed top block (title + description), as lines.
|
|
864
|
+
_renderHead() {
|
|
865
|
+
const t = this.theme
|
|
866
|
+
const lines = []
|
|
867
|
+
if (this.title) lines.push(t.title(this.title), '')
|
|
868
|
+
if (this.description) lines.push(t.description(this.description), '')
|
|
869
|
+
return lines
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// The scrollable body: every active layout item rendered to lines, plus a Map
|
|
873
|
+
// from each field to its [startLine, endLine] range (for follow-the-focus).
|
|
874
|
+
_renderBody() {
|
|
875
|
+
const t = this.theme
|
|
876
|
+
const lines = []
|
|
877
|
+
const range = new Map()
|
|
878
|
+
for (const item of this.layout) {
|
|
879
|
+
if (item.activeWhen && !item.activeWhen()) continue
|
|
880
|
+
const pad = ' '.repeat(item.indent || 0)
|
|
881
|
+
if (item.type === 'header') {
|
|
882
|
+
lines.push(pad + t.sectionTitle(item.title))
|
|
883
|
+
if (item.description) lines.push(pad + t.description(item.description))
|
|
884
|
+
continue
|
|
885
|
+
}
|
|
886
|
+
if (item.type === 'arrayHeader') {
|
|
887
|
+
lines.push(pad + t.sectionTitle(item.title))
|
|
888
|
+
if (item.description) lines.push(pad + t.description(item.description))
|
|
889
|
+
const err = this._arrayErrors[item.arrayKey.split(SEP).join('.')]
|
|
890
|
+
if (err) lines.push(pad + t.error(t.errorPrefix + err))
|
|
891
|
+
continue
|
|
892
|
+
}
|
|
893
|
+
if (item.type === 'entryHeader') {
|
|
894
|
+
lines.push(pad + t.help(`— ${item.title} ${item.index + 1} —`))
|
|
895
|
+
continue
|
|
896
|
+
}
|
|
897
|
+
const f = item.field
|
|
898
|
+
const start = lines.length
|
|
899
|
+
for (const ln of f.view().split('\n')) lines.push(pad + ln)
|
|
900
|
+
const menu = typeof f.menuView === 'function' ? f.menuView() : ''
|
|
901
|
+
if (menu) for (const ln of menu.split('\n')) lines.push(pad + ln)
|
|
902
|
+
range.set(f, [start, lines.length - 1])
|
|
903
|
+
lines.push('')
|
|
904
|
+
}
|
|
905
|
+
return { lines, range }
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
_renderFooter(scrollable) {
|
|
909
|
+
const t = this.theme
|
|
910
|
+
if (this._formValidating) {
|
|
911
|
+
const frame = this._formSpinner ? this._formSpinner.view() : ''
|
|
912
|
+
const n = this._queue.length
|
|
913
|
+
const k = Math.min(this._queueAt + 1, n)
|
|
914
|
+
return [t.validating(` ${frame ? frame + ' ' : ''}validating… ${k}/${n}`)]
|
|
915
|
+
}
|
|
916
|
+
if (this.submitted) return [t.help(' ✓ submitted')]
|
|
917
|
+
const chord = (a) => (a && a[0]) || ''
|
|
918
|
+
const parts = [
|
|
919
|
+
`${chord(this.keys.next)} move`,
|
|
920
|
+
`${chord(this.keys.confirm)} confirm`,
|
|
921
|
+
`${chord(this.keys.submit)} submit`,
|
|
922
|
+
`${chord(this.keys.cancel)} cancel`
|
|
923
|
+
]
|
|
924
|
+
if (scrollable) parts.push('↕ scroll')
|
|
925
|
+
return [t.help(' ' + parts.join(' · '))]
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Extra rows a theme.frame() adds (border + vertical padding), measured by
|
|
929
|
+
// framing a one-line probe. 0 when there's no frame.
|
|
930
|
+
_frameRows() {
|
|
931
|
+
const frame = this.theme && this.theme.frame
|
|
932
|
+
if (typeof frame !== 'function') return 0
|
|
933
|
+
return Math.max(0, frame('x', { width: this.width }).split('\n').length - 1)
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Visible body height, or 0 when the size is unknown (render inline, no scroll).
|
|
937
|
+
// Reserves rows for the head, the footer, and any frame chrome.
|
|
938
|
+
_bodyHeight() {
|
|
939
|
+
if (!this.height) return 0
|
|
940
|
+
return Math.max(1, this.height - this._renderHead().length - 1 - this._frameRows())
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// Scroll so the focused field's line range is visible, given the body height.
|
|
944
|
+
_scrollToFocused() {
|
|
945
|
+
const avail = this._bodyHeight()
|
|
946
|
+
if (!avail) return
|
|
947
|
+
const { lines, range } = this._renderBody()
|
|
948
|
+
const max = Math.max(0, lines.length - avail)
|
|
949
|
+
const cur = this.ring.focused()
|
|
950
|
+
const r = cur && range.get(cur)
|
|
951
|
+
if (r) {
|
|
952
|
+
if (r[0] < this._scroll) this._scroll = r[0]
|
|
953
|
+
else if (r[1] > this._scroll + avail - 1) this._scroll = r[1] - avail + 1
|
|
954
|
+
}
|
|
955
|
+
this._scroll = Math.min(Math.max(0, this._scroll), max)
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
_scrollBy(n) {
|
|
959
|
+
const avail = this._bodyHeight()
|
|
960
|
+
if (avail) {
|
|
961
|
+
const { lines } = this._renderBody()
|
|
962
|
+
const max = Math.max(0, lines.length - avail)
|
|
963
|
+
this._scroll = Math.min(Math.max(0, this._scroll + n), max)
|
|
964
|
+
}
|
|
965
|
+
return [this, null]
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Wrap the composed view in the theme's frame (border/background/padding), if
|
|
969
|
+
// any. Kept separate so the scroll math (which reserves the frame's rows) and
|
|
970
|
+
// the final paint agree.
|
|
971
|
+
_frame(inner) {
|
|
972
|
+
const frame = this.theme && this.theme.frame
|
|
973
|
+
return typeof frame === 'function'
|
|
974
|
+
? frame(inner, { width: this.width, title: this.title })
|
|
975
|
+
: inner
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
view() {
|
|
979
|
+
const head = this._renderHead()
|
|
980
|
+
const { lines: body } = this._renderBody()
|
|
981
|
+
const avail = this._bodyHeight()
|
|
982
|
+
|
|
983
|
+
// Unknown size, or it all fits: render inline (footer not scrollable).
|
|
984
|
+
if (!avail || body.length <= avail) {
|
|
985
|
+
const footer = this._renderFooter(false)
|
|
986
|
+
return this._frame(head.concat(body, footer).join('\n'))
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// Scroll: a fixed-height window of the body keeps the layout stable.
|
|
990
|
+
const max = Math.max(0, body.length - avail)
|
|
991
|
+
const start = Math.min(Math.max(0, this._scroll), max)
|
|
992
|
+
const window = body.slice(start, start + avail)
|
|
993
|
+
return this._frame(head.concat(window, this._renderFooter(true)).join('\n'))
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function create(opts) {
|
|
998
|
+
return new Form(opts)
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
async function run(form, opts = {}) {
|
|
1002
|
+
let result = null
|
|
1003
|
+
class Runner {
|
|
1004
|
+
init() {
|
|
1005
|
+
return form.init()
|
|
1006
|
+
}
|
|
1007
|
+
update(msg) {
|
|
1008
|
+
if (msg && msg.type === 'form.submit') {
|
|
1009
|
+
result = msg.values
|
|
1010
|
+
return [this, quit]
|
|
1011
|
+
}
|
|
1012
|
+
if (msg && msg.type === 'form.cancel') {
|
|
1013
|
+
result = null
|
|
1014
|
+
return [this, quit]
|
|
1015
|
+
}
|
|
1016
|
+
const [f, cmd] = form.update(msg)
|
|
1017
|
+
form = f
|
|
1018
|
+
return [this, cmd]
|
|
1019
|
+
}
|
|
1020
|
+
view() {
|
|
1021
|
+
return form.view()
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
await new Program(new Runner(), opts).run()
|
|
1025
|
+
return result
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
module.exports = { create, run, Form, fromDef }
|