bare-tui 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.
Files changed (48) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +13 -0
  3. package/README.md +261 -0
  4. package/ansi.js +34 -0
  5. package/commands.js +67 -0
  6. package/components/autocomplete.js +208 -0
  7. package/components/checkbox.js +68 -0
  8. package/components/filepicker.js +232 -0
  9. package/components/focus.js +129 -0
  10. package/components/help.js +117 -0
  11. package/components/list.js +172 -0
  12. package/components/paginator.js +111 -0
  13. package/components/progress.js +78 -0
  14. package/components/radio.js +113 -0
  15. package/components/select.js +165 -0
  16. package/components/spinner.js +70 -0
  17. package/components/stopwatch.js +88 -0
  18. package/components/table.js +130 -0
  19. package/components/textarea.js +279 -0
  20. package/components/textinput.js +135 -0
  21. package/components/timer.js +91 -0
  22. package/components/viewport.js +104 -0
  23. package/docs/autocomplete.md +80 -0
  24. package/docs/checkbox.md +43 -0
  25. package/docs/filepicker.md +80 -0
  26. package/docs/focus.md +68 -0
  27. package/docs/help.md +45 -0
  28. package/docs/list.md +47 -0
  29. package/docs/paginator.md +43 -0
  30. package/docs/progress.md +36 -0
  31. package/docs/radio.md +49 -0
  32. package/docs/select.md +54 -0
  33. package/docs/spinner.md +54 -0
  34. package/docs/stopwatch.md +52 -0
  35. package/docs/table.md +52 -0
  36. package/docs/textarea.md +43 -0
  37. package/docs/textinput.md +51 -0
  38. package/docs/timer.md +57 -0
  39. package/docs/viewport.md +39 -0
  40. package/index.d.ts +1 -0
  41. package/index.js +71 -0
  42. package/key.js +29 -0
  43. package/messages.js +62 -0
  44. package/mouse.js +102 -0
  45. package/package.json +59 -1
  46. package/program.js +387 -0
  47. package/renderer.js +67 -0
  48. package/style.js +473 -0
@@ -0,0 +1,208 @@
1
+ // autocomplete — a single-line input with a filtered suggestion menu.
2
+ //
3
+ // A composition over textinput (the same pattern list uses for its filter): it
4
+ // folds keys into an embedded field, then offers a menu of suggestions that
5
+ // match what's been typed. Typical use is a slash-command palette — the menu
6
+ // opens only once the line begins with a trigger character (default '/'), so
7
+ // ordinary prose typed into the same field never pops a menu.
8
+ //
9
+ // const ac = autocomplete.create({
10
+ // prompt: '> ',
11
+ // placeholder: 'message, or / for commands',
12
+ // suggestions: [
13
+ // { name: 'help', desc: 'show help' },
14
+ // { name: 'clear', desc: 'clear the screen' }
15
+ // ]
16
+ // }).focus()
17
+ //
18
+ // The component never consumes enter — the parent decides what it means. A
19
+ // common pattern is "enter submits the line, but the highlighted command when
20
+ // the menu is open", which both completes and runs in one keystroke:
21
+ //
22
+ // if (key.matches(msg, 'enter')) {
23
+ // const s = ac.open && ac.selectedSuggestion()
24
+ // return [this, submit(s ? '/' + s.name : ac.value)]
25
+ // }
26
+ // const [f, cmd] = ac.update(msg); this.input = f; return [this, cmd]
27
+ //
28
+ // While the menu is open ↑/↓ move the highlight, tab accepts it (completing the
29
+ // line), and esc dismisses it; every other key edits the field. The view()
30
+ // draws the input line only — call menuView() to render the dropdown wherever
31
+ // it fits (above a bottom-anchored prompt, below a top one).
32
+ const key = require('../key')
33
+ const ansi = require('../ansi')
34
+ const { style } = require('../style')
35
+ const textinput = require('./textinput')
36
+
37
+ const dim = (s) => ansi.modifierDim + s + ansi.modifierReset
38
+
39
+ const keys = {
40
+ up: key.binding({ keys: ['up', 'ctrl+p'], help: { key: '↑', desc: 'prev' } }),
41
+ down: key.binding({ keys: ['down', 'ctrl+n'], help: { key: '↓', desc: 'next' } }),
42
+ accept: key.binding({ keys: ['tab'], help: { key: 'tab', desc: 'accept' } }),
43
+ dismiss: key.binding({ keys: ['esc'], help: { key: 'esc', desc: 'dismiss' } })
44
+ }
45
+
46
+ // Normalise a suggestion to { name, desc }. Bare strings become name-only.
47
+ function normalize(s) {
48
+ if (typeof s === 'string') return { name: s, desc: '' }
49
+ return { name: String(s.name ?? ''), desc: String(s.desc ?? '') }
50
+ }
51
+
52
+ class Autocomplete {
53
+ constructor(opts = {}) {
54
+ this.trigger = opts.trigger ?? '/'
55
+ this.suggestions = (opts.suggestions || []).map(normalize)
56
+ this.maxVisible = opts.maxVisible || 6
57
+ this.width = opts.width || 0 // 0 = size the menu to its content
58
+
59
+ this.input = textinput.create({
60
+ value: opts.value || '',
61
+ placeholder: opts.placeholder || '',
62
+ prompt: opts.prompt || '',
63
+ focused: !!opts.focused
64
+ })
65
+
66
+ this.selected = 0 // highlight within the current matches
67
+ this.dismissed = false // esc / accept hides the menu until the value changes
68
+ this._lastValue = this.input.value
69
+ }
70
+
71
+ get value() {
72
+ return this.input.value
73
+ }
74
+ get focused() {
75
+ return this.input.focused
76
+ }
77
+
78
+ focus() {
79
+ this.input.focus()
80
+ return this
81
+ }
82
+ blur() {
83
+ this.input.blur()
84
+ return this
85
+ }
86
+
87
+ setValue(v) {
88
+ this.input.setValue(v)
89
+ this._lastValue = this.input.value
90
+ return this
91
+ }
92
+ reset() {
93
+ this.input.reset()
94
+ this.selected = 0
95
+ this.dismissed = false
96
+ this._lastValue = ''
97
+ return this
98
+ }
99
+ setSuggestions(list) {
100
+ this.suggestions = (list || []).map(normalize)
101
+ return this
102
+ }
103
+
104
+ // The suggestions matching the typed text, or [] when no menu is warranted.
105
+ // A menu is warranted once the line begins with the trigger; the text after
106
+ // it is a case-insensitive prefix filter over suggestion names.
107
+ matches() {
108
+ const v = this.input.value
109
+ if (!v.startsWith(this.trigger)) return []
110
+ const typed = v.slice(this.trigger.length).toLowerCase()
111
+ return this.suggestions.filter((s) => s.name.toLowerCase().startsWith(typed))
112
+ }
113
+
114
+ // Whether the dropdown is currently showing.
115
+ get open() {
116
+ return this.input.focused && !this.dismissed && this.matches().length > 0
117
+ }
118
+
119
+ // The highlighted suggestion, or null.
120
+ selectedSuggestion() {
121
+ const m = this.matches()
122
+ return m.length ? m[Math.min(this.selected, m.length - 1)] : null
123
+ }
124
+
125
+ // Complete the line to the highlighted suggestion and close the menu.
126
+ accept() {
127
+ const s = this.selectedSuggestion()
128
+ if (!s) return this
129
+ this.input.setValue(this.trigger + s.name + ' ')
130
+ this.input.cursor = this.input.value.length
131
+ this.dismissed = true
132
+ this._lastValue = this.input.value
133
+ return this
134
+ }
135
+
136
+ update(msg) {
137
+ if (!msg || msg.type !== 'key' || !this.input.focused) return [this, null]
138
+
139
+ // Menu navigation takes priority over field editing while it's open.
140
+ if (this.open) {
141
+ if (key.matches(msg, keys.up)) return [this._move(-1), null]
142
+ if (key.matches(msg, keys.down)) return [this._move(1), null]
143
+ if (key.matches(msg, keys.accept)) return [this.accept(), null]
144
+ if (key.matches(msg, keys.dismiss)) {
145
+ this.dismissed = true
146
+ return [this, null]
147
+ }
148
+ }
149
+
150
+ const [input, cmd] = this.input.update(msg)
151
+ this.input = input
152
+ // Any edit to the text re-opens the menu and re-clamps the highlight.
153
+ if (this.input.value !== this._lastValue) {
154
+ this.dismissed = false
155
+ this._lastValue = this.input.value
156
+ this.selected = 0
157
+ }
158
+ return [this, cmd]
159
+ }
160
+
161
+ _move(dir) {
162
+ const n = this.matches().length
163
+ if (n) this.selected = (this.selected + dir + n) % n
164
+ return this
165
+ }
166
+
167
+ view() {
168
+ return this.input.view()
169
+ }
170
+
171
+ // The dropdown, or '' when closed. Rows are `/name desc`, the highlight in
172
+ // reverse video; a footer notes any matches scrolled out of view.
173
+ menuView() {
174
+ if (!this.open) return ''
175
+ const all = this.matches()
176
+ const sel = Math.min(this.selected, all.length - 1)
177
+
178
+ // Keep the highlight inside a maxVisible-row window.
179
+ const start = Math.max(0, Math.min(sel - this.maxVisible + 1, all.length - this.maxVisible))
180
+ const top = all.length > this.maxVisible ? Math.max(0, start) : 0
181
+ const shown = all.slice(top, top + this.maxVisible)
182
+
183
+ const nameW = Math.max(...all.map((s) => (this.trigger + s.name).length))
184
+ const rows = shown.map((s, i) => {
185
+ const isSel = top + i === sel
186
+ const label = (this.trigger + s.name).padEnd(nameW)
187
+ const text = s.desc ? label + ' ' + s.desc : label
188
+ if (isSel) {
189
+ return style()
190
+ .foreground('black')
191
+ .background('magenta')
192
+ .render(' ' + text + ' ')
193
+ }
194
+ return ' ' + style().foreground('magenta').render(label) + (s.desc ? ' ' + dim(s.desc) : '')
195
+ })
196
+
197
+ if (all.length > shown.length) {
198
+ rows.push(dim(` …${all.length - shown.length} more`))
199
+ }
200
+ return rows.join('\n')
201
+ }
202
+ }
203
+
204
+ function create(opts) {
205
+ return new Autocomplete(opts)
206
+ }
207
+
208
+ module.exports = { create, Autocomplete, keys }
@@ -0,0 +1,68 @@
1
+ // checkbox — a single boolean toggle.
2
+ //
3
+ // A leaf input in the textinput mould: it only reacts when focused, so a parent
4
+ // can broadcast keys to several controls and only the focused one moves. Space
5
+ // toggles; it deliberately never consumes enter, so a parent form can keep enter
6
+ // for "submit" while a checkbox has focus.
7
+ //
8
+ // const agree = checkbox.create({ label: 'I agree', checked: false }).focus()
9
+ // // in update: const [c] = agree.update(msg); this.agree = c
10
+ // // in view: agree.view() // "› [x] I agree"
11
+ //
12
+ // Focus is shown with a leading '›' pointer (a blank when blurred), the same
13
+ // idiom radio and select use, so a stack of controls reads consistently and the
14
+ // pointer never shifts the line width.
15
+ const key = require('../key')
16
+
17
+ const keys = {
18
+ // Space only — enter is left for the parent (e.g. submit).
19
+ toggle: key.binding({ keys: ['space'], help: { key: 'space', desc: 'toggle' } })
20
+ }
21
+
22
+ class Checkbox {
23
+ constructor(opts = {}) {
24
+ this.label = opts.label || ''
25
+ this.checked = !!opts.checked
26
+ this.focused = !!opts.focused
27
+ this.checkedGlyph = opts.checkedGlyph || '[x]'
28
+ this.uncheckedGlyph = opts.uncheckedGlyph || '[ ]'
29
+ }
30
+
31
+ focus() {
32
+ this.focused = true
33
+ return this
34
+ }
35
+
36
+ blur() {
37
+ this.focused = false
38
+ return this
39
+ }
40
+
41
+ setChecked(v) {
42
+ this.checked = !!v
43
+ return this
44
+ }
45
+
46
+ toggle() {
47
+ this.checked = !this.checked
48
+ return this
49
+ }
50
+
51
+ update(msg) {
52
+ if (!this.focused || !msg || msg.type !== 'key') return [this, null]
53
+ if (key.matches(msg, keys.toggle)) this.toggle()
54
+ return [this, null]
55
+ }
56
+
57
+ view() {
58
+ const pointer = this.focused ? '› ' : ' '
59
+ const box = this.checked ? this.checkedGlyph : this.uncheckedGlyph
60
+ return pointer + (this.label ? box + ' ' + this.label : box)
61
+ }
62
+ }
63
+
64
+ function create(opts) {
65
+ return new Checkbox(opts)
66
+ }
67
+
68
+ module.exports = { create, Checkbox, keys }
@@ -0,0 +1,232 @@
1
+ // filepicker — browse a filesystem and pick a file.
2
+ //
3
+ // Dependency-injected so the framework core stays filesystem-free: pass your
4
+ // own { fs, path } to create(), or let it lazily require('bare-fs') /
5
+ // require('bare-path') — that require only runs when you actually construct a
6
+ // filepicker, so consumers who don't use it never pull those modules in.
7
+ //
8
+ // const fp = filepicker.create({ height: 12 }) // real fs
9
+ // const fp = filepicker.create({ ...filepicker.mock(tree), cwd: '/' }) // tests
10
+ //
11
+ // Directory reads happen through Cmds (async), surfacing as Msgs:
12
+ // { type: 'filepicker.entries', dir, entries }
13
+ // { type: 'filepicker.error', dir, error }
14
+ // { type: 'filepicker.select', path } // a file was chosen
15
+ //
16
+ // The only fs surface used is fs.readdir(dir, { withFileTypes: true }, cb); the
17
+ // only path surface is path.join and path.dirname. The mock implements exactly
18
+ // that, so tests need no real I/O.
19
+ const key = require('../key')
20
+ const { style } = require('../style')
21
+
22
+ const dirStyle = (s) => style().foreground('cyan').render(s)
23
+ const selectedStyle = (s) => style().reverse(true).render(s)
24
+ const dim = (s) => style().faint(true).render(s)
25
+
26
+ const keys = {
27
+ up: key.binding({ keys: ['up', 'k'], help: { key: '↑/k', desc: 'up' } }),
28
+ down: key.binding({ keys: ['down', 'j'], help: { key: '↓/j', desc: 'down' } }),
29
+ open: key.binding({ keys: ['enter', 'right', 'l'], help: { key: '↵', desc: 'open' } }),
30
+ back: key.binding({ keys: ['backspace', 'left', 'h'], help: { key: '⌫', desc: 'up dir' } })
31
+ }
32
+
33
+ function listDir(fs, dir) {
34
+ return new Promise((resolve, reject) => {
35
+ fs.readdir(dir, { withFileTypes: true }, (err, ents) => {
36
+ if (err) return reject(err)
37
+ resolve(ents.map((e) => ({ name: e.name, directory: e.isDirectory() })))
38
+ })
39
+ })
40
+ }
41
+
42
+ function sortEntries(entries, showHidden) {
43
+ return entries
44
+ .filter((e) => showHidden || !e.name.startsWith('.'))
45
+ .sort((a, b) => {
46
+ if (a.directory !== b.directory) return a.directory ? -1 : 1
47
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
48
+ })
49
+ }
50
+
51
+ class FilePicker {
52
+ constructor(opts = {}) {
53
+ this.fs = opts.fs
54
+ this.path = opts.path
55
+ this.cwd = opts.cwd
56
+ this.height = opts.height || 12
57
+ this.showHidden = !!opts.showHidden
58
+
59
+ this.entries = []
60
+ this.cursor = 0
61
+ this.offset = 0
62
+ this.selected = null
63
+ this.error = null
64
+ this.loading = true
65
+ }
66
+
67
+ init() {
68
+ return this._read(this.cwd)
69
+ }
70
+
71
+ selectedPath() {
72
+ return this.selected
73
+ }
74
+
75
+ // A Cmd that reads `dir` and resolves to an entries (or error) Msg.
76
+ _read(dir) {
77
+ const fs = this.fs
78
+ const showHidden = this.showHidden
79
+ return () =>
80
+ listDir(fs, dir).then(
81
+ (entries) => ({
82
+ type: 'filepicker.entries',
83
+ dir,
84
+ entries: sortEntries(entries, showHidden)
85
+ }),
86
+ (err) => ({
87
+ type: 'filepicker.error',
88
+ dir,
89
+ error: (err && err.message) || String(err)
90
+ })
91
+ )
92
+ }
93
+
94
+ update(msg) {
95
+ if (!msg) return [this, null]
96
+
97
+ if (msg.type === 'filepicker.entries' && msg.dir === this.cwd) {
98
+ this.entries = msg.entries
99
+ this.cursor = 0
100
+ this.offset = 0
101
+ this.loading = false
102
+ this.error = null
103
+ return [this, null]
104
+ }
105
+ if (msg.type === 'filepicker.error' && msg.dir === this.cwd) {
106
+ this.error = msg.error
107
+ this.entries = []
108
+ this.loading = false
109
+ return [this, null]
110
+ }
111
+ if (msg.type === 'key') return this._key(msg)
112
+ return [this, null]
113
+ }
114
+
115
+ _key(msg) {
116
+ if (key.matches(msg, keys.up)) this._move(-1)
117
+ else if (key.matches(msg, keys.down)) this._move(1)
118
+ else if (key.matches(msg, keys.back)) return this._open(this.path.dirname(this.cwd))
119
+ else if (key.matches(msg, keys.open)) {
120
+ const entry = this.entries[this.cursor]
121
+ if (!entry) return [this, null]
122
+ const full = this.path.join(this.cwd, entry.name)
123
+ if (entry.directory) return this._open(full)
124
+ this.selected = full
125
+ return [this, () => ({ type: 'filepicker.select', path: full })]
126
+ }
127
+ return [this, null]
128
+ }
129
+
130
+ _open(dir) {
131
+ this.cwd = dir
132
+ this.loading = true
133
+ return [this, this._read(dir)]
134
+ }
135
+
136
+ _move(delta) {
137
+ if (!this.entries.length) return
138
+ this.cursor = Math.max(0, Math.min(this.cursor + delta, this.entries.length - 1))
139
+ if (this.cursor < this.offset) this.offset = this.cursor
140
+ else if (this.cursor >= this.offset + this.height) {
141
+ this.offset = this.cursor - this.height + 1
142
+ }
143
+ }
144
+
145
+ view() {
146
+ const out = [
147
+ style()
148
+ .bold(true)
149
+ .render(this.cwd || '')
150
+ ]
151
+ const rows = []
152
+
153
+ if (this.loading) rows.push(dim(' loading…'))
154
+ else if (this.error) rows.push(dim(' ⚠ ' + this.error))
155
+ else if (!this.entries.length) rows.push(dim(' (empty)'))
156
+ else {
157
+ const end = Math.min(this.offset + this.height, this.entries.length)
158
+ for (let p = this.offset; p < end; p++) {
159
+ const entry = this.entries[p]
160
+ const label = entry.directory ? entry.name + '/' : entry.name
161
+ const text = (p === this.cursor ? '› ' : ' ') + label
162
+ rows.push(p === this.cursor ? selectedStyle(text) : entry.directory ? dirStyle(text) : text)
163
+ }
164
+ }
165
+ while (rows.length < this.height) rows.push('')
166
+
167
+ return out.concat(rows).join('\n')
168
+ }
169
+ }
170
+
171
+ function create(opts = {}) {
172
+ // Lazy require: only consumers that build a filepicker load bare-fs/bare-path.
173
+ const fs = opts.fs || require('bare-fs')
174
+ const path = opts.path || require('bare-path')
175
+ const cwd = opts.cwd || path.resolve('.')
176
+ return new FilePicker({ ...opts, fs, path, cwd })
177
+ }
178
+
179
+ // ── mock filesystem ──────────────────────────────────────────────────────
180
+ //
181
+ // filepicker.mock(tree) returns { fs, path, root } backed by a plain object:
182
+ // keys are entry names, an object value is a directory, anything else a file.
183
+ //
184
+ // const m = filepicker.mock({ docs: { 'a.md': null }, 'readme.txt': null })
185
+ // const fp = filepicker.create({ fs: m.fs, path: m.path, cwd: m.root })
186
+
187
+ const mockPath = {
188
+ sep: '/',
189
+ join: (...parts) => parts.join('/').replace(/\/{2,}/g, '/') || '/',
190
+ dirname: (p) => {
191
+ const segs = p.replace(/\/+$/, '').split('/')
192
+ segs.pop()
193
+ const d = segs.join('/')
194
+ return d === '' ? '/' : d
195
+ },
196
+ basename: (p) => p.replace(/\/+$/, '').split('/').pop() || '/',
197
+ resolve: (p) => p
198
+ }
199
+
200
+ function resolveNode(tree, root, p) {
201
+ if (p === root) return tree
202
+ let rel = p
203
+ if (root !== '/' && p.startsWith(root)) rel = p.slice(root.length)
204
+ const segs = rel.split('/').filter(Boolean)
205
+ let node = tree
206
+ for (const s of segs) {
207
+ if (node && typeof node === 'object' && s in node) node = node[s]
208
+ else return undefined
209
+ }
210
+ return node
211
+ }
212
+
213
+ function mock(tree, opts = {}) {
214
+ const root = opts.root || '/'
215
+ const fs = {
216
+ readdir(dir, options, cb) {
217
+ if (typeof options === 'function') cb = options
218
+ const node = resolveNode(tree, root, dir)
219
+ if (!node || typeof node !== 'object') {
220
+ return cb(new Error('ENOTDIR: ' + dir))
221
+ }
222
+ const ents = Object.keys(node).map((name) => {
223
+ const isDir = !!(node[name] && typeof node[name] === 'object')
224
+ return { name, isDirectory: () => isDir }
225
+ })
226
+ cb(null, ents)
227
+ }
228
+ }
229
+ return { fs, path: mockPath, root }
230
+ }
231
+
232
+ module.exports = { create, FilePicker, mock }
@@ -0,0 +1,129 @@
1
+ // focus — an ordered focus ring across a set of child components.
2
+ //
3
+ // This is connective tissue, not a widget: it has no view(). It holds an
4
+ // ordered list of focusable children and owns the one job every multi-field
5
+ // screen reimplements — moving focus between them with tab/shift+tab, blurring
6
+ // the old and focusing the new. It relies only on the component contract the
7
+ // built-ins already follow (`focus()` / `blur()` and `update(msg) → [m, cmd]`).
8
+ //
9
+ // this.ring = focus.create({
10
+ // items: [
11
+ // textinput.create({ prompt: '> ' }),
12
+ // radio.create({ options: ['a', 'b'] }),
13
+ // checkbox.create({ label: 'ok' })
14
+ // ]
15
+ // })
16
+ //
17
+ // update(msg) {
18
+ // // Handle global / submit keys FIRST so a focused child can't swallow them.
19
+ // if (key.matches(msg, 'ctrl+c')) return [this, quit]
20
+ // if (key.matches(msg, 'enter')) return [this, this._submit()]
21
+ // // Then let the ring move focus and route the rest to the focused child,
22
+ // // threading its Cmd back up.
23
+ // const [ring, cmd] = this.ring.update(msg)
24
+ // this.ring = ring
25
+ // return [this, cmd]
26
+ // }
27
+ // view() { return this.ring.items.map((it) => it.view()).join('\n') }
28
+ //
29
+ // Navigation defaults to tab/shift+tab ONLY — deliberately not up/down, because
30
+ // the focusable children (radio, select, list, textarea) use the arrows
31
+ // internally. Pass `keys` to override if your children don't. focus() syncs the
32
+ // children on construction so exactly the indexed one is focused.
33
+ const key = require('../key')
34
+
35
+ const defaultKeys = {
36
+ next: key.binding({ keys: ['tab'], help: { key: 'tab', desc: 'next field' } }),
37
+ prev: key.binding({ keys: ['shift+tab'], help: { key: 'shift+tab', desc: 'prev field' } })
38
+ }
39
+
40
+ class Focus {
41
+ constructor(opts = {}) {
42
+ this.items = opts.items ? opts.items.slice() : []
43
+ this.index = opts.index || 0
44
+ this.keys = opts.keys || defaultKeys
45
+ this._clamp()
46
+ this._sync()
47
+ }
48
+
49
+ // The currently focused child, or null when there are none.
50
+ focused() {
51
+ return this.items[this.index] || null
52
+ }
53
+
54
+ setItems(items) {
55
+ this.items = items ? items.slice() : []
56
+ this._clamp()
57
+ this._sync()
58
+ return this
59
+ }
60
+
61
+ // Focus a specific index (clamped); blurs the rest.
62
+ focus(i) {
63
+ this.index = i
64
+ this._clamp()
65
+ this._sync()
66
+ return this
67
+ }
68
+
69
+ next() {
70
+ this._move(1)
71
+ return this
72
+ }
73
+
74
+ prev() {
75
+ this._move(-1)
76
+ return this
77
+ }
78
+
79
+ update(msg) {
80
+ // Ring navigation is handled here, before the child sees the key. Check the
81
+ // more-specific 'prev' chord (shift+tab) first: key matching also accepts a
82
+ // bare name, so a 'tab' binding would otherwise swallow 'shift+tab' too.
83
+ if (key.matches(msg, this.keys.prev)) {
84
+ this._move(-1)
85
+ return [this, null]
86
+ }
87
+ if (key.matches(msg, this.keys.next)) {
88
+ this._move(1)
89
+ return [this, null]
90
+ }
91
+
92
+ // Everything else goes to the focused child; thread its Cmd up.
93
+ const cur = this.items[this.index]
94
+ if (cur && typeof cur.update === 'function') {
95
+ const [m, cmd] = cur.update(msg)
96
+ this.items[this.index] = m
97
+ return [this, cmd]
98
+ }
99
+ return [this, null]
100
+ }
101
+
102
+ _move(dir) {
103
+ if (this.items.length < 2) return
104
+ this.index = (this.index + dir + this.items.length) % this.items.length
105
+ this._sync()
106
+ }
107
+
108
+ _clamp() {
109
+ if (!this.items.length) this.index = 0
110
+ else this.index = Math.max(0, Math.min(this.index, this.items.length - 1))
111
+ }
112
+
113
+ // Make exactly items[index] focused; blur the rest. Guards children that
114
+ // don't implement the focus contract.
115
+ _sync() {
116
+ this.items.forEach((it, i) => {
117
+ if (!it) return
118
+ if (i === this.index) {
119
+ if (typeof it.focus === 'function') it.focus()
120
+ } else if (typeof it.blur === 'function') it.blur()
121
+ })
122
+ }
123
+ }
124
+
125
+ function create(opts) {
126
+ return new Focus(opts)
127
+ }
128
+
129
+ module.exports = { create, Focus, defaultKeys }