@trendr/core 0.1.0 → 0.2.1

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/src/hooks.js CHANGED
@@ -1,5 +1,5 @@
1
- import { onCleanup, createSignal as rawCreateSignal } from './signal.js'
2
- import { getContext, getTheme, registerHook, getInstanceLayout } from './renderer.js'
1
+ import { onCleanup, createSignal as rawCreateSignal, createSignalRaw } from './signal.js'
2
+ import { getContext, getTheme, getCursor, registerHook, getInstanceLayout, getFrameStats } from './renderer.js'
3
3
  import { setTitle } from './ansi.js'
4
4
 
5
5
  export function useState(initial) {
@@ -18,6 +18,18 @@ export function useInput(handler) {
18
18
  ref.current = handler
19
19
  }
20
20
 
21
+ export function useMouse(handler) {
22
+ const ref = registerHook(() => {
23
+ const ctx = getContext()
24
+ if (!ctx) throw new Error('useMouse must be called within a mounted component')
25
+ const state = { current: handler }
26
+ const unsub = ctx.input.onMouse((event) => state.current(event))
27
+ onCleanup(unsub)
28
+ return state
29
+ })
30
+ ref.current = handler
31
+ }
32
+
21
33
  export function useResize(handler) {
22
34
  const ref = registerHook(() => {
23
35
  const ctx = getContext()
@@ -56,8 +68,136 @@ export function useStdout() {
56
68
  return ctx.stream
57
69
  }
58
70
 
71
+ export function useRepaint() {
72
+ const ctx = getContext()
73
+ if (!ctx) throw new Error('useRepaint must be called within a mounted component')
74
+ return ctx.repaint
75
+ }
76
+
77
+ export function useFrameStats() {
78
+ return getFrameStats()
79
+ }
80
+
59
81
  export function useTitle(title) {
60
82
  const ctx = getContext()
61
83
  if (!ctx) throw new Error('useTitle must be called within a mounted component')
62
84
  ctx.stream.write(setTitle(title))
63
85
  }
86
+
87
+ export function useTimeout(fn, ms) {
88
+ const ref = registerHook(() => {
89
+ const state = { current: fn }
90
+ const id = setTimeout(() => state.current(), ms)
91
+ onCleanup(() => clearTimeout(id))
92
+ return state
93
+ })
94
+ ref.current = fn
95
+ }
96
+
97
+ export function useScrollDrag({ barX, barY, thumbHeight, trackHeight, maxOffset, scrollOffset, onScroll }) {
98
+ const drag = registerHook(() => ({ active: false, startY: 0, startOffset: 0 }))
99
+
100
+ useMouse((event) => {
101
+ if (barX == null || thumbHeight <= 0) return
102
+
103
+ if (event.action === 'press' && event.button === 'left' && event.x === barX) {
104
+ if (event.y >= barY && event.y < barY + thumbHeight) {
105
+ drag.active = true
106
+ drag.startY = event.y
107
+ drag.startOffset = scrollOffset
108
+ event.stopPropagation()
109
+ }
110
+ }
111
+
112
+ if (event.action === 'drag' && drag.active) {
113
+ const dy = event.y - drag.startY
114
+ const travel = Math.max(1, trackHeight - thumbHeight)
115
+ const ratio = maxOffset / travel
116
+ const newOffset = Math.max(0, Math.min(maxOffset, Math.round(drag.startOffset + dy * ratio)))
117
+ onScroll(newOffset)
118
+ event.stopPropagation()
119
+ }
120
+
121
+ if (event.action === 'release' && drag.active) {
122
+ drag.active = false
123
+ }
124
+ })
125
+ }
126
+
127
+ export function useCursor(propCursor, focused) {
128
+ const config = getCursor(propCursor)
129
+
130
+ const state = registerHook(() => {
131
+ const [visible, setVisible] = createSignalRaw(true)
132
+ let id = null
133
+
134
+ function start(rate) {
135
+ stop()
136
+ id = setInterval(() => setVisible(v => !v), rate)
137
+ }
138
+
139
+ function stop() {
140
+ if (id !== null) { clearInterval(id); id = null }
141
+ setVisible(true)
142
+ }
143
+
144
+ onCleanup(stop)
145
+ return { visible, setVisible, start, stop, blinking: false, rate: 0 }
146
+ })
147
+
148
+ const shouldBlink = config.blink && focused
149
+ if (shouldBlink && (!state.blinking || state.rate !== config.rate)) {
150
+ state.start(config.rate)
151
+ state.blinking = true
152
+ state.rate = config.rate
153
+ } else if (!shouldBlink && state.blinking) {
154
+ state.stop()
155
+ state.blinking = false
156
+ }
157
+
158
+ if (!shouldBlink) state.setVisible(true)
159
+
160
+ function reset() {
161
+ if (!state.blinking) return
162
+ state.setVisible(true)
163
+ state.start(config.rate)
164
+ }
165
+
166
+ function cursorStyle() {
167
+ if (!focused || !state.visible()) return null
168
+ const s = {}
169
+ if (config.color) s.color = config.color
170
+ if (config.bg) s.bg = config.bg
171
+ if (!config.color && !config.bg) s.inverse = true
172
+ if (config.style === 'underline') { s.underline = true; delete s.inverse }
173
+ return s
174
+ }
175
+
176
+ return { config, visible: state.visible, cursorStyle, reset }
177
+ }
178
+
179
+ export function useAsync(fn, { immediate = false } = {}) {
180
+ const state = registerHook(() => {
181
+ const [status, setStatus] = createSignalRaw('idle')
182
+ const [data, setData] = createSignalRaw(null)
183
+ const [error, setError] = createSignalRaw(null)
184
+ let generation = 0
185
+
186
+ function run(...args) {
187
+ const gen = ++generation
188
+ setStatus('loading')
189
+ setData(null)
190
+ setError(null)
191
+ fn(...args).then(
192
+ result => { if (gen === generation) { setData(result); setStatus('success') } },
193
+ err => { if (gen === generation) { setError(err); setStatus('error') } },
194
+ )
195
+ }
196
+
197
+ if (immediate) run()
198
+
199
+ return { status, data, error, run }
200
+ })
201
+
202
+ return state
203
+ }
package/src/input.js CHANGED
@@ -30,6 +30,28 @@ const SPECIAL_KEYS = {
30
30
  ' ': 'space',
31
31
  }
32
32
 
33
+ const MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/
34
+
35
+ export function parseMouse(raw) {
36
+ const m = MOUSE_RE.exec(raw)
37
+ if (!m) return null
38
+ const cb = parseInt(m[1], 10)
39
+ const x = parseInt(m[2], 10) - 1
40
+ const y = parseInt(m[3], 10) - 1
41
+ const release = m[4] === 'm'
42
+ const button = cb & 3
43
+ const scroll = (cb & 64) !== 0
44
+ const motion = (cb & 32) !== 0
45
+
46
+ if (scroll) {
47
+ return { type: 'mouse', action: 'scroll', direction: button === 0 ? 'up' : 'down', x, y }
48
+ }
49
+
50
+ const buttonName = button === 0 ? 'left' : button === 1 ? 'middle' : 'right'
51
+ const action = release ? 'release' : motion ? 'drag' : 'press'
52
+ return { type: 'mouse', action, button: buttonName, x, y }
53
+ }
54
+
33
55
  export function parseKey(data) {
34
56
  const raw = typeof data === 'string' ? data : data.toString()
35
57
 
@@ -69,6 +91,15 @@ export function splitKeys(data) {
69
91
  if (raw[i] === '\x1b') {
70
92
  if (i + 1 < raw.length && raw[i + 1] === '[') {
71
93
  let j = i + 2
94
+ // sgr mouse: \x1b[<Cb;Cx;CyM or m
95
+ if (j < raw.length && raw[j] === '<') {
96
+ j++
97
+ while (j < raw.length && ((raw[j] >= '0' && raw[j] <= '9') || raw[j] === ';')) j++
98
+ if (j < raw.length) j++
99
+ keys.push(raw.slice(i, j))
100
+ i = j
101
+ continue
102
+ }
72
103
  while (j < raw.length && raw[j] >= '0' && raw[j] <= '9') j++
73
104
  if (j < raw.length && raw[j] === ';') {
74
105
  j++
@@ -98,12 +129,24 @@ export function splitKeys(data) {
98
129
  }
99
130
 
100
131
  export function createInputHandler(stream) {
101
- const listeners = new Set()
132
+ const keyListeners = new Set()
133
+ const mouseListeners = new Set()
102
134
 
103
135
  function dispatch(keyStr) {
136
+ const mouse = parseMouse(keyStr)
137
+ if (mouse) {
138
+ mouse.stopPropagation = () => { mouse._stopped = true }
139
+ const snapshot = [...mouseListeners].reverse()
140
+ for (const fn of snapshot) {
141
+ fn(mouse)
142
+ if (mouse._stopped) break
143
+ }
144
+ return
145
+ }
146
+
104
147
  const event = parseKey(keyStr)
105
148
  event.stopPropagation = () => { event._stopped = true }
106
- const snapshot = [...listeners].reverse()
149
+ const snapshot = [...keyListeners].reverse()
107
150
  for (const fn of snapshot) {
108
151
  fn(event)
109
152
  if (event._stopped) break
@@ -131,13 +174,22 @@ export function createInputHandler(stream) {
131
174
  }
132
175
 
133
176
  function onKey(fn) {
134
- listeners.add(fn)
135
- if (listeners.size === 1) attach()
177
+ keyListeners.add(fn)
178
+ if (keyListeners.size + mouseListeners.size === 1) attach()
179
+ return () => {
180
+ keyListeners.delete(fn)
181
+ if (keyListeners.size + mouseListeners.size === 0) detach()
182
+ }
183
+ }
184
+
185
+ function onMouse(fn) {
186
+ mouseListeners.add(fn)
187
+ if (keyListeners.size + mouseListeners.size === 1) attach()
136
188
  return () => {
137
- listeners.delete(fn)
138
- if (listeners.size === 0) detach()
189
+ mouseListeners.delete(fn)
190
+ if (keyListeners.size + mouseListeners.size === 0) detach()
139
191
  }
140
192
  }
141
193
 
142
- return { onKey, attach, detach }
194
+ return { onKey, onMouse, attach, detach }
143
195
  }
package/src/layout.js CHANGED
@@ -1,6 +1,20 @@
1
1
  import { wordWrap, measureText } from './wrap.js'
2
2
  import { Fragment } from './element.js'
3
3
 
4
+ const ALL_EDGES = { top: true, right: true, bottom: true, left: true }
5
+
6
+ export function resolveBorderEdges(style) {
7
+ if (!style.border) return { top: 0, right: 0, bottom: 0, left: 0 }
8
+ if (!style.borderEdges) return { top: 1, right: 1, bottom: 1, left: 1 }
9
+ const e = style.borderEdges
10
+ return {
11
+ top: e.top ? 1 : 0,
12
+ right: e.right ? 1 : 0,
13
+ bottom: e.bottom ? 1 : 0,
14
+ left: e.left ? 1 : 0,
15
+ }
16
+ }
17
+
4
18
  export function computeLayout(node, rect) {
5
19
  if (!node) return
6
20
 
@@ -51,25 +65,79 @@ export function computeLayout(node, rect) {
51
65
  if (!children || children.length === 0) return
52
66
 
53
67
  const pad = resolvePadding(style)
54
- const border = style.border ? 1 : 0
55
- const innerX = box.x + pad.left + border
56
- const innerY = box.y + pad.top + border
57
- const innerW = Math.max(0, box.width - pad.left - pad.right - border * 2)
58
- const innerH = Math.max(0, box.height - pad.top - pad.bottom - border * 2)
68
+ const be = resolveBorderEdges(style)
69
+ const innerX = box.x + pad.left + be.left
70
+ const innerY = box.y + pad.top + be.top
71
+ const innerW = Math.max(0, box.width - pad.left - pad.right - be.left - be.right)
72
+ const innerH = Math.max(0, box.height - pad.top - pad.bottom - be.top - be.bottom)
73
+
74
+ const flowChildren = []
75
+ const absChildren = []
76
+ for (const child of children) {
77
+ if (childStyle(child).position === 'absolute') absChildren.push(child)
78
+ else flowChildren.push(child)
79
+ }
59
80
 
60
81
  const isRow = style.flexDirection === 'row'
61
82
  const gap = style.gap ?? 0
83
+ const isScroll = style.overflow === 'scroll'
84
+ const flexMain = isScroll ? 100000 : (isRow ? innerW : innerH)
85
+
86
+ if (flowChildren.length > 0) {
87
+ layoutFlex(flowChildren, {
88
+ x: innerX,
89
+ y: innerY,
90
+ width: isRow && isScroll ? flexMain : innerW,
91
+ height: !isRow && isScroll ? flexMain : innerH,
92
+ isRow,
93
+ gap,
94
+ justifyContent: style.justifyContent ?? 'flex-start',
95
+ alignItems: style.alignItems ?? 'stretch',
96
+ })
97
+ }
98
+
99
+ if (isScroll && flowChildren.length > 0) {
100
+ let maxEdge = 0
101
+ for (const child of flowChildren) {
102
+ const cl = getLeaf(child)?._layout
103
+ if (cl) {
104
+ const edge = isRow ? (cl.x + cl.width - innerX) : (cl.y + cl.height - innerY)
105
+ if (edge > maxEdge) maxEdge = edge
106
+ }
107
+ }
108
+ node._contentHeight = maxEdge
109
+ }
110
+
111
+ for (const child of absChildren) {
112
+ layoutAbsolute(child, innerX, innerY, innerW, innerH)
113
+ }
114
+ }
115
+
116
+ function layoutAbsolute(child, areaX, areaY, areaW, areaH) {
117
+ const cs = childStyle(child)
118
+
119
+ let w, h
120
+ if (cs.left != null && cs.right != null) {
121
+ w = Math.max(0, areaW - (cs.left ?? 0) - (cs.right ?? 0))
122
+ } else {
123
+ w = resolveSize(cs.width, areaW) ?? measureChild(child, cs, false, areaW, areaH).width
124
+ }
62
125
 
63
- layoutFlex(children, {
64
- x: innerX,
65
- y: innerY,
66
- width: innerW,
67
- height: innerH,
68
- isRow,
69
- gap,
70
- justifyContent: style.justifyContent ?? 'flex-start',
71
- alignItems: style.alignItems ?? 'stretch',
72
- })
126
+ if (cs.top != null && cs.bottom != null) {
127
+ h = Math.max(0, areaH - (cs.top ?? 0) - (cs.bottom ?? 0))
128
+ } else {
129
+ h = resolveSize(cs.height, areaH) ?? measureChild(child, cs, false, areaW, areaH).height
130
+ }
131
+
132
+ let x = areaX
133
+ if (cs.left != null) x = areaX + cs.left
134
+ else if (cs.right != null) x = areaX + areaW - w - cs.right
135
+
136
+ let y = areaY
137
+ if (cs.top != null) y = areaY + cs.top
138
+ else if (cs.bottom != null) y = areaY + areaH - h - cs.bottom
139
+
140
+ computeLayout(child, { x, y, width: Math.max(0, w), height: Math.max(0, h) })
73
141
  }
74
142
 
75
143
  function layoutFlex(children, ctx) {
@@ -186,8 +254,12 @@ function measureChild(child, cs, isRow, availW, availH) {
186
254
 
187
255
  if (leaf.type === 'text') {
188
256
  const text = extractText(leaf)
257
+ const overflow = cs.overflow
189
258
  if (!text) { w = explicitW ?? 0; h = explicitH ?? 1 }
190
- else {
259
+ else if (overflow === 'nowrap' || overflow === 'truncate') {
260
+ w = explicitW ?? Math.min(availW, measureText(text))
261
+ h = explicitH ?? 1
262
+ } else {
191
263
  const maxW = explicitW ?? availW
192
264
  const lines = wordWrap(text, maxW)
193
265
  const textWidth = Math.min(maxW, Math.max(...lines.map(l => measureText(l))))
@@ -214,8 +286,8 @@ function measureIntrinsic(node, availW, availH) {
214
286
 
215
287
  const style = node.props?.style ?? {}
216
288
  const pad = resolvePadding(style)
217
- const border = style.border ? 1 : 0
218
- const chrome = { x: pad.left + pad.right + border * 2, y: pad.top + pad.bottom + border * 2 }
289
+ const be = resolveBorderEdges(style)
290
+ const chrome = { x: pad.left + pad.right + be.left + be.right, y: pad.top + pad.bottom + be.top + be.bottom }
219
291
  const innerW = availW - chrome.x
220
292
  const innerH = availH - chrome.y
221
293
 
package/src/list.js CHANGED
@@ -1,46 +1,171 @@
1
- import { jsx } from '../jsx-runtime.js'
1
+ import { jsx, jsxs } from '../jsx-runtime.js'
2
2
  import { createSignal } from './signal.js'
3
- import { useInput, useLayout } from './hooks.js'
3
+ import { useInput, useMouse, useLayout, useTheme, useScrollDrag } from './hooks.js'
4
4
 
5
- export function List({ items, selected: selectedProp, onSelect, height, renderItem, header, focused = true }) {
5
+ export function List({ items, selected: selectedProp, onSelect, renderItem, header, headerHeight = 1, focused = true, interactive = focused, itemHeight = 1, scrollbar = false, stickyHeader = false, gap = 0, scrolloff = 2 }) {
6
+ const { accent = 'cyan' } = useTheme()
6
7
  const [selectedInternal, setSelectedInternal] = createSignal(0)
8
+ const [scrollState, setScrollState] = createSignal(0)
7
9
  const layout = useLayout()
8
10
 
9
11
  const selected = selectedProp ?? selectedInternal()
10
12
  const setSelected = onSelect ?? setSelectedInternal
11
13
 
12
- const rawH = height ?? layout.height
13
- const visibleH = header && rawH > 0 ? rawH - 1 : rawH
14
+ const viewH = layout.height
15
+ const contentH = layout.contentHeight ?? 0
16
+ const headerH = header ? headerHeight : 0
17
+ const sticky = header && stickyHeader
18
+
19
+ const innerHeaderH = sticky ? 0 : headerH
20
+
21
+ // when sticky, layout measures the outer wrapper so we can't derive item height from it
22
+ // use itemHeight directly and compute content from item count
23
+ const avgH = !sticky && contentH > 0 && items.length > 0
24
+ ? (contentH - headerH) / items.length
25
+ : itemHeight
26
+
27
+ const scrollViewH = sticky ? viewH - headerH : viewH
28
+ const scrollContentH = sticky ? items.length * avgH : contentH
29
+
30
+ const maxOffset = Math.max(0, scrollContentH - scrollViewH)
14
31
 
15
32
  useInput(({ key, ctrl }) => {
16
- if (!focused) return
33
+ if (!interactive) return
17
34
 
18
35
  const len = items.length
19
36
  if (len === 0) return
20
37
 
21
- const h = visibleH || 10
22
- const half = Math.max(1, Math.floor(h / 2))
38
+ const pageItems = viewH > 0 ? Math.max(1, Math.floor(viewH / avgH)) : 10
39
+ const half = Math.max(1, Math.floor(pageItems / 2))
23
40
 
24
41
  if (key === 'up' || key === 'k') setSelected(Math.max(0, selected - 1))
25
42
  else if (key === 'down' || key === 'j') setSelected(Math.min(len - 1, selected + 1))
26
- else if (key === 'pageup' || (ctrl && key === 'b')) setSelected(Math.max(0, selected - h))
27
- else if (key === 'pagedown' || (ctrl && key === 'f')) setSelected(Math.min(len - 1, selected + h))
43
+ else if (key === 'pageup' || (ctrl && key === 'b')) setSelected(Math.max(0, selected - pageItems))
44
+ else if (key === 'pagedown' || (ctrl && key === 'f')) setSelected(Math.min(len - 1, selected + pageItems))
28
45
  else if (ctrl && key === 'u') setSelected(Math.max(0, selected - half))
29
46
  else if (ctrl && key === 'd') setSelected(Math.min(len - 1, selected + half))
30
47
  else if (key === 'home' || key === 'g') setSelected(0)
31
48
  else if (key === 'end' || key === 'G') setSelected(len - 1)
32
49
  })
33
50
 
34
- const scrollOffset = visibleH > 0 && items.length > visibleH
35
- ? Math.max(0, Math.min(selected - Math.floor(visibleH / 2), items.length - visibleH))
51
+ useMouse((event) => {
52
+ if (!focused) return
53
+ const len = items.length
54
+ if (len === 0) return
55
+ const { x, y } = event
56
+ if (x < layout.x || x >= layout.x + layout.width || y < layout.y || y >= layout.y + layout.height) return
57
+
58
+ if (event.action === 'scroll') {
59
+ if (event.direction === 'up') setSelected(Math.max(0, selected - 1))
60
+ else setSelected(Math.min(len - 1, selected + 1))
61
+ event.stopPropagation()
62
+ return
63
+ }
64
+
65
+ if (event.action === 'press' && event.button === 'left') {
66
+ const headerOffset = sticky ? headerH : (header ? headerH : 0)
67
+ const relY = y - layout.y - headerOffset
68
+ const idx = Math.floor((relY + scrollOffset) / Math.max(1, avgH))
69
+ if (idx >= 0 && idx < len) {
70
+ setSelected(idx)
71
+ event.stopPropagation()
72
+ }
73
+ }
74
+ })
75
+
76
+ const hasBar = scrollbar && scrollViewH > 0 && scrollContentH > scrollViewH
77
+ const barThumbH = hasBar ? Math.max(1, Math.round((scrollViewH / scrollContentH) * scrollViewH)) : 0
78
+
79
+ useScrollDrag({
80
+ barX: hasBar ? layout.x + layout.width - 1 : null,
81
+ barY: layout.y + (sticky ? headerH : 0),
82
+ thumbHeight: barThumbH,
83
+ trackHeight: scrollViewH,
84
+ maxOffset,
85
+ scrollOffset: selected * avgH,
86
+ onScroll: (offset) => {
87
+ const idx = Math.round(offset / Math.max(1, avgH))
88
+ setSelected(Math.max(0, Math.min(items.length - 1, idx)))
89
+ },
90
+ })
91
+
92
+ const itemTop = selected * avgH + innerHeaderH
93
+ const itemBottom = itemTop + avgH
94
+
95
+ let scrollOffset = 0
96
+ if (scrollViewH > 0 && scrollContentH > scrollViewH) {
97
+ const margin = scrolloff * avgH
98
+ scrollOffset = scrollState()
99
+ if (itemTop - margin < scrollOffset) scrollOffset = itemTop - margin
100
+ if (itemBottom + margin > scrollOffset + scrollViewH) scrollOffset = itemBottom + margin - scrollViewH
101
+ scrollOffset = Math.max(0, Math.min(maxOffset, Math.round(scrollOffset)))
102
+ if (scrollOffset !== scrollState()) setScrollState(scrollOffset)
103
+ }
104
+
105
+ const scrollChildren = []
106
+ if (header && !sticky) scrollChildren.push(header)
107
+ for (let i = 0; i < items.length; i++) {
108
+ scrollChildren.push(renderItem(items[i], { selected: i === selected, index: i, focused }))
109
+ }
110
+
111
+ const scrollBox = jsx('box', {
112
+ style: {
113
+ flexDirection: 'column',
114
+ flexGrow: 1,
115
+ overflow: 'scroll',
116
+ scrollOffset,
117
+ gap,
118
+ },
119
+ children: scrollChildren,
120
+ })
121
+
122
+ const list = sticky
123
+ ? jsxs('box', {
124
+ style: { flexDirection: 'column', flexGrow: 1 },
125
+ children: [header, scrollBox],
126
+ })
127
+ : scrollBox
128
+
129
+ if (!scrollbar || scrollViewH <= 0 || scrollContentH <= scrollViewH) return list
130
+
131
+ const thumbH = Math.max(1, Math.round((scrollViewH / scrollContentH) * scrollViewH))
132
+ const thumbStart = maxOffset > 0
133
+ ? Math.round((scrollOffset / maxOffset) * (scrollViewH - thumbH))
36
134
  : 0
37
135
 
38
- const children = []
39
- if (header) children.push(header)
136
+ const barH = sticky ? scrollViewH : viewH
137
+ const barChildren = []
138
+ for (let i = 0; i < barH; i++) {
139
+ const isThumb = i >= thumbStart && i < thumbStart + thumbH
140
+ barChildren.push(
141
+ jsx('text', {
142
+ key: i,
143
+ style: { color: isThumb ? (focused ? accent : 'gray') : 'gray', dim: !isThumb },
144
+ children: isThumb ? '\u2588' : '\u2502',
145
+ })
146
+ )
147
+ }
40
148
 
41
- for (let i = scrollOffset; i < items.length; i++) {
42
- children.push(renderItem(items[i], { selected: i === selected, index: i, focused }))
149
+ const scrollBarCol = jsx('box', {
150
+ style: { width: 1, flexDirection: 'column' },
151
+ children: barChildren,
152
+ })
153
+
154
+ if (sticky) {
155
+ return jsxs('box', {
156
+ style: { flexDirection: 'column', flexGrow: 1 },
157
+ children: [
158
+ header,
159
+ jsxs('box', {
160
+ style: { flexDirection: 'row', flexGrow: 1, gap: 1 },
161
+ children: [scrollBox, scrollBarCol],
162
+ }),
163
+ ],
164
+ })
43
165
  }
44
166
 
45
- return jsx('box', { style: { flexDirection: 'column', flexGrow: 1 }, children })
167
+ return jsxs('box', {
168
+ style: { flexDirection: 'row', flexGrow: 1, gap: 1 },
169
+ children: [list, scrollBarCol],
170
+ })
46
171
  }
package/src/modal.js CHANGED
@@ -2,7 +2,7 @@ import { jsx } from '../jsx-runtime.js'
2
2
  import { useInput, useTheme } from './hooks.js'
3
3
  import { registerOverlay } from './renderer.js'
4
4
 
5
- export function Modal({ open, onClose, title, children, width: w = 40 }) {
5
+ export function Modal({ open, onClose, title, children, width: w = 40, border = 'round' }) {
6
6
  const { accent = 'cyan' } = useTheme()
7
7
 
8
8
  useInput((event) => {
@@ -18,7 +18,7 @@ export function Modal({ open, onClose, title, children, width: w = 40 }) {
18
18
  const content = jsx('box', {
19
19
  style: {
20
20
  width: w,
21
- border: 'round',
21
+ border,
22
22
  borderColor: accent,
23
23
  flexDirection: 'column',
24
24
  paddingX: 1,