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/fields/base.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Field — the base class every form field extends.
|
|
2
|
+
//
|
|
3
|
+
// A field adapts a bare-tui control (textinput, select, radio, …) to the form:
|
|
4
|
+
// it carries a value `key`, a `label`/`description`, an optional `validate`
|
|
5
|
+
// function, and the current `error`. The form treats every field through one
|
|
6
|
+
// small contract:
|
|
7
|
+
//
|
|
8
|
+
// field.key // the value key in form.value()
|
|
9
|
+
// field.focus() / blur() // gate input (delegates to the control)
|
|
10
|
+
// field.focused // is this field focused
|
|
11
|
+
// field.update(msg) → [f,cmd]// fold a Msg (delegates to the control)
|
|
12
|
+
// field.value() // current value (subclass)
|
|
13
|
+
// field.setValue(v) // set it (subclass)
|
|
14
|
+
// field.controlView() // render just the control (subclass)
|
|
15
|
+
// field.menuView() // optional overlay (select dropdown); '' default
|
|
16
|
+
// field.wantsEnter() // true if enter belongs to the control right now
|
|
17
|
+
// field.validate() // run SYNC validation, set .error, return it
|
|
18
|
+
// field.view() // label + description + control + error/spinner
|
|
19
|
+
//
|
|
20
|
+
// Validation can be synchronous or asynchronous. If `validate` is an async
|
|
21
|
+
// function it's run by the form through a Cmd, with a spinner shown meanwhile;
|
|
22
|
+
// the sync helpers below (requiredError/syncValidate) are what the form uses for
|
|
23
|
+
// the instant checks, and runValidator() is what it awaits for the async one.
|
|
24
|
+
//
|
|
25
|
+
// Subclasses set `this.control` (a bare-tui component) and implement value(),
|
|
26
|
+
// setValue() and controlView(). The base delegates focus/blur/update to the
|
|
27
|
+
// control and assembles the field's block in view(), styled through `this.theme`
|
|
28
|
+
// (set to the form's theme on construction, or the default for a lone field).
|
|
29
|
+
const { spinner } = require('bare-tui')
|
|
30
|
+
const { defaultTheme } = require('../theme')
|
|
31
|
+
|
|
32
|
+
const FRAME_SETS = { dots: spinner.dots, line: spinner.line, points: spinner.points }
|
|
33
|
+
|
|
34
|
+
// Sentinel for "this field's async validator has never passed for any value".
|
|
35
|
+
const UNCHECKED = Symbol('unchecked')
|
|
36
|
+
|
|
37
|
+
class Field {
|
|
38
|
+
constructor(opts = {}) {
|
|
39
|
+
this.key = opts.name || opts.key || ''
|
|
40
|
+
// The address of this field's value in the (possibly nested) result object.
|
|
41
|
+
// A flat field's path is just [key]; a field inside a nested object section
|
|
42
|
+
// carries the full chain (['address', 'street']). The form walks it to build
|
|
43
|
+
// and read nested values. Set by the form when it flattens nested defs.
|
|
44
|
+
this.path = Array.isArray(opts.path) && opts.path.length ? opts.path.slice() : [this.key]
|
|
45
|
+
// Section toggles (the optional-subform checkbox) set this; leaf fields don't.
|
|
46
|
+
this.isSection = false
|
|
47
|
+
// A oneOf/anyOf variant *selector* sets this: it drives which branch is live
|
|
48
|
+
// but carries no value of its own, so the form skips it when collecting.
|
|
49
|
+
this.isSelector = false
|
|
50
|
+
// Can the focus ring land here? A const (fixed discriminator) field is shown
|
|
51
|
+
// and collected but not interactive, so it opts out.
|
|
52
|
+
this.focusable = true
|
|
53
|
+
// An action button (array add/remove): focusable, carries no value, and the
|
|
54
|
+
// form runs its `action` on enter instead of validating/advancing.
|
|
55
|
+
this.isButton = false
|
|
56
|
+
this.label = opts.label || this.key
|
|
57
|
+
this.description = opts.description || ''
|
|
58
|
+
// Presentation (uiSchema): extra help line, hidden label, read-only display,
|
|
59
|
+
// initial-focus hint, and fully-hidden (collected but never shown) fields.
|
|
60
|
+
this.help = opts.help || ''
|
|
61
|
+
this.hideLabel = !!opts.hideLabel
|
|
62
|
+
this.autofocus = !!opts.autofocus
|
|
63
|
+
this.hidden = !!opts.hidden
|
|
64
|
+
if (opts.readonly || opts.hidden) this.focusable = false
|
|
65
|
+
this.required = !!opts.required
|
|
66
|
+
this.requiredMessage = opts.requiredMessage || 'required'
|
|
67
|
+
this.validatingMessage = opts.validatingMessage || 'checking…'
|
|
68
|
+
this._validate = typeof opts.validate === 'function' ? opts.validate : null
|
|
69
|
+
this._isAsync = !!this._validate && this._validate.constructor.name === 'AsyncFunction'
|
|
70
|
+
|
|
71
|
+
this.error = null
|
|
72
|
+
this.validating = false // true while an async check is in flight
|
|
73
|
+
this.spinner = null // bare-tui spinner, live only while validating
|
|
74
|
+
this._runId = 0 // generation: bumped to discard stale async results
|
|
75
|
+
// The value its async validator last *passed* for; compared to the current
|
|
76
|
+
// value to tell whether an async check still needs running (see the form's
|
|
77
|
+
// validate-on-submit pass). UNCHECKED until the first passing result.
|
|
78
|
+
this._asyncCheckedValue = UNCHECKED
|
|
79
|
+
|
|
80
|
+
this.theme = defaultTheme
|
|
81
|
+
this.control = null // set by the subclass
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
setTheme(theme) {
|
|
85
|
+
this.theme = theme
|
|
86
|
+
return this
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
focus() {
|
|
90
|
+
if (this.control) this.control.focus()
|
|
91
|
+
return this
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
blur() {
|
|
95
|
+
if (this.control) this.control.blur()
|
|
96
|
+
return this
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
get focused() {
|
|
100
|
+
return this.control ? this.control.focused : false
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
update(msg) {
|
|
104
|
+
if (!this.control) return [this, null]
|
|
105
|
+
const [c, cmd] = this.control.update(msg)
|
|
106
|
+
this.control = c
|
|
107
|
+
// Editing clears a stale error; it's re-checked on the next confirm.
|
|
108
|
+
if (this.error) this.error = null
|
|
109
|
+
return [this, cmd]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Most controls don't need enter; textarea (newline) and an open select
|
|
113
|
+
// (commit) do, and override this.
|
|
114
|
+
wantsEnter() {
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Overlay content (the select dropdown). Empty for everything else.
|
|
119
|
+
menuView() {
|
|
120
|
+
return ''
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Is `v` "empty" for the purpose of `required`? Overridable (confirm/number).
|
|
124
|
+
isEmpty(v) {
|
|
125
|
+
return v === '' || v === null || v === undefined || (Array.isArray(v) && v.length === 0)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- validation -----------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
// The required-but-empty error, or null. (sync)
|
|
131
|
+
requiredError(v) {
|
|
132
|
+
return this.required && this.isEmpty(v) ? this.requiredMessage : null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
hasValidator() {
|
|
136
|
+
return !!this._validate
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
isAsync() {
|
|
140
|
+
return this._isAsync
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Does this field have an async validator whose current (non-empty) value
|
|
144
|
+
// hasn't passed yet? Used by the form to re-run dirty async checks on submit.
|
|
145
|
+
// Editing changes the value, so a once-passed field goes dirty automatically.
|
|
146
|
+
needsAsyncCheck() {
|
|
147
|
+
if (!this._isAsync) return false
|
|
148
|
+
const v = this.value()
|
|
149
|
+
if (this.isEmpty(v)) return false
|
|
150
|
+
return this._asyncCheckedValue !== v
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Run the user validator (may return a string, null, or a Promise of those).
|
|
154
|
+
runValidator(v) {
|
|
155
|
+
return this._validate ? this._validate(v) : null
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// The instant verdict: required, then the sync validator. Async validators
|
|
159
|
+
// aren't run here (the form awaits them separately); an empty optional field
|
|
160
|
+
// is always fine.
|
|
161
|
+
syncValidate(v) {
|
|
162
|
+
if (this.required && this.isEmpty(v)) return this.requiredMessage
|
|
163
|
+
if (this.isEmpty(v)) return null
|
|
164
|
+
if (this._validate && !this._isAsync) return this._validate(v) || null
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Backwards-compatible sync entry point: set and return .error.
|
|
169
|
+
validate() {
|
|
170
|
+
this.error = this.syncValidate(this.value())
|
|
171
|
+
return this.error
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// --- spinner lifecycle (driven by the form) -------------------------------
|
|
175
|
+
|
|
176
|
+
// Start the validating spinner and return its initial tick Cmd.
|
|
177
|
+
startSpinner() {
|
|
178
|
+
const cfg = (this.theme && this.theme.spinner) || {}
|
|
179
|
+
const frames = typeof cfg.frames === 'string' ? FRAME_SETS[cfg.frames] : cfg.frames
|
|
180
|
+
this.spinner = spinner.create({ frames, fps: cfg.fps })
|
|
181
|
+
return this.spinner.init()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
stopSpinner() {
|
|
185
|
+
this.spinner = null
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// --- rendering ------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
// label (+ marker when required) · description · control · help · error|spinner.
|
|
191
|
+
// The label is omitted when hideLabel (uiSchema's `ui:label: false`).
|
|
192
|
+
view() {
|
|
193
|
+
const t = this.theme
|
|
194
|
+
const lines = []
|
|
195
|
+
if (!this.hideLabel) {
|
|
196
|
+
const labelText = this.label + (this.required ? t.requiredMarker : '')
|
|
197
|
+
lines.push(this.focused ? t.labelFocused(labelText) : t.label(labelText))
|
|
198
|
+
}
|
|
199
|
+
if (this.description) lines.push(t.description(this.description))
|
|
200
|
+
lines.push(...this.controlView().split('\n'))
|
|
201
|
+
if (this.help) lines.push(t.help(this.help))
|
|
202
|
+
|
|
203
|
+
if (this.validating) {
|
|
204
|
+
const frame = this.spinner ? this.spinner.view() : ''
|
|
205
|
+
lines.push(t.validating((frame ? frame + ' ' : '') + this.validatingMessage))
|
|
206
|
+
} else if (this.error) {
|
|
207
|
+
lines.push(t.error(t.errorPrefix + this.error))
|
|
208
|
+
}
|
|
209
|
+
return lines.join('\n')
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = { Field }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// confirm — a boolean field, wrapping bare-tui's checkbox.
|
|
2
|
+
//
|
|
3
|
+
// When `required`, "empty" means "not checked", so a required confirm must be
|
|
4
|
+
// ticked to pass (e.g. an "I agree" box).
|
|
5
|
+
const { checkbox } = require('bare-tui')
|
|
6
|
+
const { Field } = require('./base')
|
|
7
|
+
|
|
8
|
+
class ConfirmField extends Field {
|
|
9
|
+
constructor(opts = {}) {
|
|
10
|
+
super(opts)
|
|
11
|
+
// The checkbox line *is* the label, so suppress the separate label row that
|
|
12
|
+
// every other field renders above its control — otherwise the text shows
|
|
13
|
+
// twice ("Accept terms" then "[ ] Accept terms").
|
|
14
|
+
this.hideLabel = true
|
|
15
|
+
// The checkbox draws just the pointer + box; the label is added (themed) in
|
|
16
|
+
// controlView so it picks up labelFocused and the required marker.
|
|
17
|
+
this.control = checkbox.create({ label: '', checked: !!opts.value })
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
value() {
|
|
21
|
+
return this.control.checked
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
setValue(v) {
|
|
25
|
+
this.control.setChecked(v)
|
|
26
|
+
return this
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
isEmpty(v) {
|
|
30
|
+
return v !== true
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// "› [x] Accept terms *" — the box from the checkbox, the label themed here.
|
|
34
|
+
controlView() {
|
|
35
|
+
const t = this.theme
|
|
36
|
+
const text = this.label + (this.required ? t.requiredMarker : '')
|
|
37
|
+
const styled = this.focused ? t.labelFocused(text) : t.label(text)
|
|
38
|
+
return this.control.view() + ' ' + styled
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function confirm(opts) {
|
|
43
|
+
return new ConfirmField(opts)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { confirm, ConfirmField }
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// constant — a fixed value (JSON Schema `const`).
|
|
2
|
+
//
|
|
3
|
+
// A const property carries a value the user can't change — most often the
|
|
4
|
+
// discriminator of a oneOf branch (`type: { const: 'card' }`). It's shown as a
|
|
5
|
+
// static line and included in the collected value, but it's not interactive, so
|
|
6
|
+
// it opts out of the focus ring (`focusable = false`) and never errors.
|
|
7
|
+
const { Field } = require('./base')
|
|
8
|
+
|
|
9
|
+
class ConstField extends Field {
|
|
10
|
+
constructor(opts = {}) {
|
|
11
|
+
super(opts)
|
|
12
|
+
this.focusable = false
|
|
13
|
+
this.constValue = opts.value
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
value() {
|
|
17
|
+
return this.constValue
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
setValue() {
|
|
21
|
+
return this // fixed — rehydration can't change a const
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
isEmpty() {
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
syncValidate() {
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
controlView() {
|
|
33
|
+
return this.constValue === undefined ? '' : String(this.constValue)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function constant(opts) {
|
|
38
|
+
return new ConstField(opts)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { constant, ConstField }
|
package/fields/file.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// file — a path field you can either type or browse.
|
|
2
|
+
//
|
|
3
|
+
// It wraps bare-tui's textinput (the editable path, and the field's string
|
|
4
|
+
// value) and lazily opens bare-tui's filepicker as an overlay — the same
|
|
5
|
+
// host-the-control-and-expose-an-overlay shape as fields/select.js. The picker
|
|
6
|
+
// is created the first time you open it, so a form full of file fields doesn't
|
|
7
|
+
// pull in bare-fs until one is actually browsed.
|
|
8
|
+
//
|
|
9
|
+
// Interaction (mirrors select's "space opens, never steals enter"):
|
|
10
|
+
// closed — type a path; SPACE on an empty input opens the picker
|
|
11
|
+
// open — ↑/↓ move, →/l descend, ↵ open/select, ⌫ up a dir, esc closes
|
|
12
|
+
// While open the field claims enter (wantsEnter) so the form's enter reaches the
|
|
13
|
+
// picker instead of advancing; on a pick the chosen path fills the input.
|
|
14
|
+
//
|
|
15
|
+
// `pick: 'file' | 'dir'` chooses the picker mode. `fs`/`path`/`cwd`/`height`/
|
|
16
|
+
// `showHidden` are forwarded to filepicker.create — `fs`/`path` let tests inject
|
|
17
|
+
// filepicker.mock with no real I/O.
|
|
18
|
+
const { textinput, filepicker, key } = require('bare-tui')
|
|
19
|
+
const { Field } = require('./base')
|
|
20
|
+
|
|
21
|
+
const openKey = key.binding({ keys: ['space'] })
|
|
22
|
+
const closeKey = key.binding({ keys: ['esc'] })
|
|
23
|
+
|
|
24
|
+
class FileField extends Field {
|
|
25
|
+
constructor(opts = {}) {
|
|
26
|
+
super(opts)
|
|
27
|
+
this.pick = opts.pick === 'dir' ? 'dir' : 'file'
|
|
28
|
+
this.input = textinput.create({
|
|
29
|
+
value: opts.value || '',
|
|
30
|
+
placeholder:
|
|
31
|
+
opts.placeholder ||
|
|
32
|
+
(this.pick === 'dir' ? 'choose a directory…' : 'type a path or browse…'),
|
|
33
|
+
prompt: opts.prompt ?? '> ',
|
|
34
|
+
charLimit: opts.charLimit || 0
|
|
35
|
+
})
|
|
36
|
+
// The base delegates focus()/blur()/`focused` to this.control — point it at
|
|
37
|
+
// the text input (the picker is a transient overlay, not the resting state).
|
|
38
|
+
this.control = this.input
|
|
39
|
+
this._pickerOpts = {
|
|
40
|
+
pick: this.pick,
|
|
41
|
+
cwd: opts.cwd,
|
|
42
|
+
height: opts.height,
|
|
43
|
+
showHidden: opts.showHidden,
|
|
44
|
+
fs: opts.fs,
|
|
45
|
+
path: opts.path
|
|
46
|
+
}
|
|
47
|
+
this.picker = null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
value() {
|
|
51
|
+
return this.input.value
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
setValue(v) {
|
|
55
|
+
this.input.setValue(v === null || v === undefined ? '' : v)
|
|
56
|
+
return this
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// While the picker is open, enter belongs to it (descend/select), so the form
|
|
60
|
+
// must not treat enter as confirm/advance.
|
|
61
|
+
get isOpen() {
|
|
62
|
+
return this.picker !== null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
wantsEnter() {
|
|
66
|
+
return this.isOpen
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
update(msg) {
|
|
70
|
+
if (this.picker) return this._updateOpen(msg)
|
|
71
|
+
|
|
72
|
+
// Closed: SPACE on an empty input opens the browser; otherwise the text
|
|
73
|
+
// input handles the key (so you can type, and type spaces in a real path).
|
|
74
|
+
if (msg && msg.type === 'key' && key.matches(msg, openKey) && this.input.value === '') {
|
|
75
|
+
return this._open()
|
|
76
|
+
}
|
|
77
|
+
const [c, cmd] = this.input.update(msg)
|
|
78
|
+
this.input = c
|
|
79
|
+
if (this.error) this.error = null
|
|
80
|
+
return [this, cmd]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
_updateOpen(msg) {
|
|
84
|
+
if (msg && msg.type === 'filepicker.select') {
|
|
85
|
+
this.setValue(msg.path)
|
|
86
|
+
this.picker = null
|
|
87
|
+
this.error = null
|
|
88
|
+
return [this, null]
|
|
89
|
+
}
|
|
90
|
+
if (msg && msg.type === 'key' && key.matches(msg, closeKey)) {
|
|
91
|
+
this.picker = null
|
|
92
|
+
return [this, null]
|
|
93
|
+
}
|
|
94
|
+
const [p, cmd] = this.picker.update(msg)
|
|
95
|
+
this.picker = p
|
|
96
|
+
return [this, cmd]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
_open() {
|
|
100
|
+
this.picker = filepicker.create(this._pickerOpts)
|
|
101
|
+
// The first directory read is a Cmd; thread it up so the form/program runs it.
|
|
102
|
+
return [this, this.picker.init()]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Tab-away (or any focus change) closes the overlay so it can't linger.
|
|
106
|
+
blur() {
|
|
107
|
+
this.picker = null
|
|
108
|
+
return super.blur()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
menuView() {
|
|
112
|
+
return this.picker ? this.picker.view() : ''
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
controlView() {
|
|
116
|
+
const view = this.input.view()
|
|
117
|
+
if (this.isOpen) return view
|
|
118
|
+
const hint = this.theme.help(
|
|
119
|
+
this.pick === 'dir' ? ' space to browse folders' : ' space to browse'
|
|
120
|
+
)
|
|
121
|
+
return view + '\n' + hint
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function file(opts) {
|
|
126
|
+
return new FileField(opts)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { file, FileField }
|
package/fields/list.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// list — an editable list of scalar text values (add / remove rows).
|
|
2
|
+
//
|
|
3
|
+
// For a repeatable value with no fixed option set — tags, waypoints, a flag the
|
|
4
|
+
// CLI accepts many times — where multiselect (a fixed enum) and array (a
|
|
5
|
+
// repeatable OBJECT subform) don't fit. Each row is a bare-tui textinput; the
|
|
6
|
+
// field is one focus stop in the form and manages its own row cursor:
|
|
7
|
+
//
|
|
8
|
+
// ↑/↓ move between rows
|
|
9
|
+
// ↵ add a new row below and focus it
|
|
10
|
+
// ⌫ on an EMPTY row, remove it (otherwise deletes a character)
|
|
11
|
+
// tab leave the field (the form's focus ring)
|
|
12
|
+
//
|
|
13
|
+
// value() is a plain array of the non-empty rows, in order — so a multi-valued
|
|
14
|
+
// CLI flag round-trips as ["a","b"], not a single space-joined string.
|
|
15
|
+
const { key, textinput } = require('bare-tui')
|
|
16
|
+
const { Field } = require('./base')
|
|
17
|
+
|
|
18
|
+
const keys = {
|
|
19
|
+
up: key.binding({ keys: ['up'] }),
|
|
20
|
+
down: key.binding({ keys: ['down'] }),
|
|
21
|
+
add: key.binding({ keys: ['enter'] }),
|
|
22
|
+
back: key.binding({ keys: ['backspace'] })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class ListField extends Field {
|
|
26
|
+
constructor(opts = {}) {
|
|
27
|
+
super(opts)
|
|
28
|
+
this.itemPlaceholder = opts.itemPlaceholder || 'type a value…'
|
|
29
|
+
this.min = Math.max(0, opts.minItems || 0)
|
|
30
|
+
this.max = opts.maxItems > 0 ? opts.maxItems : 0 // 0 = unbounded
|
|
31
|
+
this.cursor = 0
|
|
32
|
+
this._focused = false
|
|
33
|
+
const seed = Array.isArray(opts.value) ? opts.value.map(String) : []
|
|
34
|
+
// Always keep at least one (editable) row, even when minItems is 0; empty
|
|
35
|
+
// rows are dropped from value().
|
|
36
|
+
this.rows = (seed.length ? seed : ['']).map((v) => this._mkRow(v))
|
|
37
|
+
// No single wrapped control — the delegating methods are overridden below.
|
|
38
|
+
this.control = null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_mkRow(value) {
|
|
42
|
+
return textinput.create({ value, placeholder: this.itemPlaceholder, prompt: '' })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_floor() {
|
|
46
|
+
return Math.max(this.min, 1) // never collapse below one editable row
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
focus() {
|
|
50
|
+
this._focused = true
|
|
51
|
+
this._syncFocus()
|
|
52
|
+
return this
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
blur() {
|
|
56
|
+
this._focused = false
|
|
57
|
+
for (const r of this.rows) r.blur()
|
|
58
|
+
return this
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get focused() {
|
|
62
|
+
return this._focused
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Enter belongs to the field (it adds a row) whenever it's focused, so the form
|
|
66
|
+
// doesn't treat enter as confirm/advance. Tab still moves to the next field.
|
|
67
|
+
wantsEnter() {
|
|
68
|
+
return this._focused
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
value() {
|
|
72
|
+
return this.rows.map((r) => r.value).filter((v) => v.trim() !== '')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
setValue(values) {
|
|
76
|
+
const seed = Array.isArray(values) ? values.map(String) : []
|
|
77
|
+
this.rows = (seed.length ? seed : ['']).map((v) => this._mkRow(v))
|
|
78
|
+
this.cursor = 0
|
|
79
|
+
this._syncFocus()
|
|
80
|
+
return this
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
isEmpty(v) {
|
|
84
|
+
return !Array.isArray(v) || v.length === 0
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
update(msg) {
|
|
88
|
+
if (!this._focused || !msg || msg.type !== 'key') return [this, null]
|
|
89
|
+
const row = this.rows[this.cursor]
|
|
90
|
+
|
|
91
|
+
if (key.matches(msg, keys.up)) {
|
|
92
|
+
this._move(-1)
|
|
93
|
+
return [this, null]
|
|
94
|
+
}
|
|
95
|
+
if (key.matches(msg, keys.down)) {
|
|
96
|
+
this._move(1)
|
|
97
|
+
return [this, null]
|
|
98
|
+
}
|
|
99
|
+
if (key.matches(msg, keys.add)) {
|
|
100
|
+
this._addRow()
|
|
101
|
+
return [this, null]
|
|
102
|
+
}
|
|
103
|
+
// Backspace on an EMPTY row removes it; otherwise it edits (deletes a char).
|
|
104
|
+
if (
|
|
105
|
+
key.matches(msg, keys.back) &&
|
|
106
|
+
row &&
|
|
107
|
+
row.value === '' &&
|
|
108
|
+
this.rows.length > this._floor()
|
|
109
|
+
) {
|
|
110
|
+
this._removeRow()
|
|
111
|
+
return [this, null]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const [c] = row.update(msg)
|
|
115
|
+
this.rows[this.cursor] = c
|
|
116
|
+
if (this.error) this.error = null
|
|
117
|
+
return [this, null]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
_move(delta) {
|
|
121
|
+
this.cursor = Math.max(0, Math.min(this.cursor + delta, this.rows.length - 1))
|
|
122
|
+
this._syncFocus()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
_addRow() {
|
|
126
|
+
if (this.max && this.rows.length >= this.max) return
|
|
127
|
+
this.rows.splice(this.cursor + 1, 0, this._mkRow(''))
|
|
128
|
+
this.cursor++
|
|
129
|
+
this._syncFocus()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
_removeRow() {
|
|
133
|
+
this.rows.splice(this.cursor, 1)
|
|
134
|
+
this.cursor = Math.max(0, Math.min(this.cursor, this.rows.length - 1))
|
|
135
|
+
this._syncFocus()
|
|
136
|
+
if (this.error) this.error = null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
_syncFocus() {
|
|
140
|
+
this.rows.forEach((r, i) => (this._focused && i === this.cursor ? r.focus() : r.blur()))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
controlView() {
|
|
144
|
+
const t = this.theme
|
|
145
|
+
const lines = this.rows.map((r, i) => {
|
|
146
|
+
const pointer = this._focused && i === this.cursor ? '› ' : ' '
|
|
147
|
+
return pointer + r.view()
|
|
148
|
+
})
|
|
149
|
+
if (this._focused) lines.push(t.help(' ↵ add · ⌫ on an empty row removes'))
|
|
150
|
+
return lines.join('\n')
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function list(opts) {
|
|
155
|
+
return new ListField(opts)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = { list, ListField, keys }
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// multiselect — choose many from a list of toggles.
|
|
2
|
+
//
|
|
3
|
+
// bare-tui has no checkbox *group*, so this field implements its own little
|
|
4
|
+
// control inline (cursor + a checked set) rather than wrapping one. It still
|
|
5
|
+
// presents the standard Field contract, so the form treats it like any other.
|
|
6
|
+
// value() is an array of the checked values, in option order.
|
|
7
|
+
const { key } = require('bare-tui')
|
|
8
|
+
const { Field } = require('./base')
|
|
9
|
+
|
|
10
|
+
const keys = {
|
|
11
|
+
up: key.binding({ keys: ['up', 'k'] }),
|
|
12
|
+
down: key.binding({ keys: ['down', 'j'] }),
|
|
13
|
+
toggle: key.binding({ keys: ['space'] })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalize(opt) {
|
|
17
|
+
if (opt !== null && typeof opt === 'object') {
|
|
18
|
+
const value = 'value' in opt ? opt.value : opt.label
|
|
19
|
+
return { label: String(opt.label ?? opt.value ?? ''), value }
|
|
20
|
+
}
|
|
21
|
+
return { label: String(opt), value: opt }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class MultiSelectField extends Field {
|
|
25
|
+
constructor(opts = {}) {
|
|
26
|
+
super(opts)
|
|
27
|
+
this.options = (opts.options || []).map(normalize)
|
|
28
|
+
this.cursor = 0
|
|
29
|
+
this.checked = new Set() // indices
|
|
30
|
+
this._focused = false
|
|
31
|
+
this.onGlyph = opts.onGlyph || '[x]'
|
|
32
|
+
this.offGlyph = opts.offGlyph || '[ ]'
|
|
33
|
+
if (Array.isArray(opts.value)) this.setValue(opts.value)
|
|
34
|
+
// No wrapped control; we override the delegating methods below.
|
|
35
|
+
this.control = null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
focus() {
|
|
39
|
+
this._focused = true
|
|
40
|
+
return this
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
blur() {
|
|
44
|
+
this._focused = false
|
|
45
|
+
return this
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get focused() {
|
|
49
|
+
return this._focused
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
value() {
|
|
53
|
+
const out = []
|
|
54
|
+
this.options.forEach((o, i) => {
|
|
55
|
+
if (this.checked.has(i)) out.push(o.value)
|
|
56
|
+
})
|
|
57
|
+
return out
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
setValue(values) {
|
|
61
|
+
const want = new Set(values || [])
|
|
62
|
+
this.checked = new Set()
|
|
63
|
+
this.options.forEach((o, i) => {
|
|
64
|
+
if (want.has(o.value)) this.checked.add(i)
|
|
65
|
+
})
|
|
66
|
+
return this
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
update(msg) {
|
|
70
|
+
if (!this._focused || !msg || msg.type !== 'key') return [this, null]
|
|
71
|
+
if (key.matches(msg, keys.up)) this.cursor = Math.max(0, this.cursor - 1)
|
|
72
|
+
else if (key.matches(msg, keys.down)) {
|
|
73
|
+
this.cursor = Math.min(this.options.length - 1, this.cursor + 1)
|
|
74
|
+
} else if (key.matches(msg, keys.toggle)) {
|
|
75
|
+
if (this.checked.has(this.cursor)) this.checked.delete(this.cursor)
|
|
76
|
+
else this.checked.add(this.cursor)
|
|
77
|
+
if (this.error) this.error = null
|
|
78
|
+
}
|
|
79
|
+
return [this, null]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
controlView() {
|
|
83
|
+
return this.options
|
|
84
|
+
.map((o, i) => {
|
|
85
|
+
const pointer = this._focused && i === this.cursor ? '› ' : ' '
|
|
86
|
+
const box = this.checked.has(i) ? this.onGlyph : this.offGlyph
|
|
87
|
+
return pointer + box + ' ' + o.label
|
|
88
|
+
})
|
|
89
|
+
.join('\n')
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function multiselect(opts) {
|
|
94
|
+
return new MultiSelectField(opts)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { multiselect, MultiSelectField, keys }
|