@trendr/core 0.2.0 → 0.2.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 CHANGED
@@ -6,14 +6,18 @@
6
6
  </pre>
7
7
 
8
8
 
9
- JSX components, signals, per-cell diffing and flexbox without React and Yoga. Terminals are character grids, not DOM trees. Why reconcile a virtual DOM to write escape sequences?
9
+ JSX components, signals, per-cell diffing and flexbox without React and [Yoga](https://github.com/facebook/yoga/issues/1859). Terminals are character grids, not DOM trees. Why reconcile a virtual DOM to write escape sequences?
10
10
 
11
11
  4-16x faster frame times and 580x less I/O per render than popular TUI frameworks. No dependencies. [benchmarks](bench/README.md)
12
12
 
13
- https://github.com/user-attachments/assets/70c91ab3-659a-4bb0-939a-961dcfbaba61
13
+ https://github.com/user-attachments/assets/6e84bb4b-a99e-46f2-a235-1ee4be62c0ae
14
14
 
15
15
  ## Usage
16
16
 
17
+ ```bash
18
+ npm i @trendr/core
19
+ ```
20
+
17
21
  Requires esbuild (or similar) for JSX transformation.
18
22
 
19
23
  ```json
@@ -208,10 +212,11 @@ useHotkey('alt+enter', () => submit(), { when: () => isFocused })
208
212
 
209
213
  ### useLayout
210
214
 
211
- Returns the component's computed layout rectangle.
215
+ Returns a live reference to the component's computed layout rectangle. Values update in-place each frame, including after terminal resize.
212
216
 
213
217
  ```jsx
214
- const { x, y, width, height } = useLayout()
218
+ const layout = useLayout()
219
+ // layout.x, layout.y, layout.width, layout.height, layout.contentHeight
215
220
  ```
216
221
 
217
222
  ### useResize
@@ -286,12 +291,14 @@ const stream = useStdout() // the output stream (process.stdout or custom)
286
291
  Forces a full repaint. Useful after spawning an external process (e.g. `$EDITOR`).
287
292
 
288
293
  ```jsx
294
+ import { useRepaint, useStdout, exitAltScreen, showCursor, altScreen, hideCursor } from '@trendr/core'
295
+
289
296
  const repaint = useRepaint()
297
+ const stdout = useStdout()
290
298
 
291
- // leave alt screen, spawn editor, re-enter, repaint
292
- stdout.write('\x1b[?1049l\x1b[?25h')
299
+ stdout.write(exitAltScreen + showCursor)
293
300
  execSync(`${process.env.EDITOR} ${file}`, { stdio: 'inherit' })
294
- stdout.write('\x1b[?1049h\x1b[?25l')
301
+ stdout.write(altScreen + hideCursor)
295
302
  repaint()
296
303
  ```
297
304
 
@@ -437,6 +444,8 @@ Scrollable list with keyboard navigation.
437
444
  onSelect={setIndex}
438
445
  focused={fm.is('list')}
439
446
  scrollbar={true} // default false
447
+ scrolloff={2} // items of margin from edges when scrolling (default 2)
448
+ interactive={true} // handle keyboard input (default: same as focused)
440
449
  header={<text>title</text>}
441
450
  headerHeight={1} // default 1, rows the header occupies
442
451
  renderItem={(item, { selected, index, focused }) => (
@@ -463,6 +472,47 @@ Scrollable list with keyboard navigation.
463
472
 
464
473
  Keys: j/k or up/down, g/G for top/bottom, ctrl-d/u half page, ctrl-f/b full page, pageup/pagedown.
465
474
 
475
+ ### PickList
476
+
477
+ Used in [pick-list](examples/pick-list.jsx)
478
+
479
+ Filterable list with live search. Text input at the top filters a scrollable list below. Navigate the list with up/down or ctrl-n/ctrl-p while typing.
480
+
481
+ ```jsx
482
+ <PickList
483
+ items={data}
484
+ focused={fm.is('search')}
485
+ placeholder="search..."
486
+ onSelect={item => {}} // Enter on highlighted item
487
+ onCancel={() => {}} // Escape
488
+ onChange={query => {}} // every keystroke in the filter
489
+ clearOnSelect={false} // reset filter on select (default false)
490
+ scrollbar={true} // default false
491
+ scrolloff={2} // items of margin from edges (default 2, inherited from List)
492
+ gap={1} // space between input and list (default 0)
493
+ filter={(query, item) => {}} // custom filter (default: case-insensitive includes)
494
+ />
495
+ ```
496
+
497
+ Multi-row items with `renderItem`, `itemHeight`, and `itemGap`:
498
+
499
+ ```jsx
500
+ <PickList
501
+ items={packages}
502
+ itemHeight={3}
503
+ itemGap={1}
504
+ renderItem={(pkg, { selected, focused }) => (
505
+ <box style={{ flexDirection: 'column', bg: selected ? accent : null, paddingX: 1 }}>
506
+ <text style={{ bold: true }}>{pkg.name}</text>
507
+ <text style={{ color: 'gray' }}>{pkg.desc}</text>
508
+ <text style={{ color: 'yellow' }}>{pkg.downloads}</text>
509
+ </box>
510
+ )}
511
+ />
512
+ ```
513
+
514
+ Keys: type to filter, up/down or ctrl-n/ctrl-p to navigate, enter to select, escape to cancel. All bash-style editing keys work (ctrl-a/e/u/k/w).
515
+
466
516
  ### Table
467
517
 
468
518
  Used in [components](examples/components.jsx), [custom-table](examples/custom-table.jsx)
package/index.js CHANGED
@@ -16,6 +16,7 @@ export { Modal } from './src/modal.js'
16
16
  export { Radio } from './src/radio.js'
17
17
  export { Button } from './src/button.js'
18
18
  export { ScrollableText } from './src/scrollable-text.js'
19
+ export { PickList } from './src/pick-list.js'
19
20
  export { ScrollBox } from './src/scroll-box.js'
20
21
  export { SplitPane } from './src/split-pane.js'
21
22
  export { Task } from './src/task.js'
@@ -24,5 +25,6 @@ export { useFocus } from './src/focus.js'
24
25
  export { useHotkey } from './src/hotkey.js'
25
26
  export { useToast } from './src/toast.js'
26
27
  export { Fragment } from './src/element.js'
28
+ export { altScreen, exitAltScreen, showCursor, hideCursor, clearScreen, clearLine } from './src/ansi.js'
27
29
  export { animated, useAnimated, spring, ease, decay } from './src/animation.js'
28
30
  export { linear, easeInQuad, easeOutQuad, easeInOutQuad, easeInCubic, easeOutCubic, easeInOutCubic, easeOutElastic, easeOutBounce } from './src/animation.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trendr/core",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "direct-mode TUI renderer with JSX, signals, and per-cell diffing",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -43,6 +43,8 @@
43
43
  "progress": "node esbuild.config.js && node dist/progress.js",
44
44
  "shimmer": "node esbuild.config.js && node dist/shimmer.js",
45
45
  "spinner": "node esbuild.config.js && node dist/spinner.js",
46
+ "pick-list": "node esbuild.config.js && node dist/pick-list.js",
47
+ "table-demo": "node esbuild.config.js && node dist/table-demo.js",
46
48
  "demo": "node esbuild.config.js && node demo/server.js",
47
49
  "demo:build": "node esbuild.config.js"
48
50
  },
package/src/list.js CHANGED
@@ -2,9 +2,10 @@ import { jsx, jsxs } from '../jsx-runtime.js'
2
2
  import { createSignal } from './signal.js'
3
3
  import { useInput, useMouse, useLayout, useTheme, useScrollDrag } from './hooks.js'
4
4
 
5
- export function List({ items, selected: selectedProp, onSelect, renderItem, header, headerHeight = 1, focused = true, itemHeight = 1, scrollbar = false, stickyHeader = false, gap = 0 }) {
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
6
  const { accent = 'cyan' } = useTheme()
7
7
  const [selectedInternal, setSelectedInternal] = createSignal(0)
8
+ const [scrollState, setScrollState] = createSignal(0)
8
9
  const layout = useLayout()
9
10
 
10
11
  const selected = selectedProp ?? selectedInternal()
@@ -29,7 +30,7 @@ export function List({ items, selected: selectedProp, onSelect, renderItem, head
29
30
  const maxOffset = Math.max(0, scrollContentH - scrollViewH)
30
31
 
31
32
  useInput(({ key, ctrl }) => {
32
- if (!focused) return
33
+ if (!interactive) return
33
34
 
34
35
  const len = items.length
35
36
  if (len === 0) return
@@ -93,11 +94,12 @@ export function List({ items, selected: selectedProp, onSelect, renderItem, head
93
94
 
94
95
  let scrollOffset = 0
95
96
  if (scrollViewH > 0 && scrollContentH > scrollViewH) {
96
- const centered = itemTop - Math.floor((scrollViewH - avgH) / 2)
97
- scrollOffset = Math.max(0, Math.min(maxOffset, centered))
98
- if (itemTop < scrollOffset) scrollOffset = itemTop
99
- if (itemBottom > scrollOffset + scrollViewH) scrollOffset = itemBottom - 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
100
101
  scrollOffset = Math.max(0, Math.min(maxOffset, Math.round(scrollOffset)))
102
+ if (scrollOffset !== scrollState()) setScrollState(scrollOffset)
101
103
  }
102
104
 
103
105
  const scrollChildren = []
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,
@@ -0,0 +1,204 @@
1
+ import { jsx, jsxs } from '../jsx-runtime.js'
2
+ import { createSignal } from './signal.js'
3
+ import { useInput, useLayout, useTheme, useCursor } from './hooks.js'
4
+ import { List } from './list.js'
5
+
6
+ export function PickList({ items, onSelect, onCancel, onChange, focused = true, placeholder = 'search...', filter: filterFn, renderItem, maxVisible = 10, scrollbar = false, scrolloff = 0, itemHeight = 1, itemGap = 0, gap = 0, clearOnSelect = false, style: userStyle, cursor: cursorProp }) {
7
+ const { accent = 'cyan' } = useTheme()
8
+ const defaults = {
9
+ borderColor: accent,
10
+ cursorBg: accent,
11
+ cursorTextColor: 'black',
12
+ color: null,
13
+ }
14
+ const s = { ...defaults, ...userStyle }
15
+
16
+ const [query, setQuery] = createSignal('')
17
+ const [textCursor, setTextCursor] = createSignal(0)
18
+ const [listCursor, setListCursor] = createSignal(0)
19
+ const layout = useLayout()
20
+ const { cursorStyle, reset: resetBlink } = useCursor(cursorProp, focused)
21
+
22
+ const defaultFilter = (q, item) => {
23
+ const label = typeof item === 'string' ? item : (item.label ?? item.name ?? '')
24
+ return label.toLowerCase().includes(q.toLowerCase())
25
+ }
26
+
27
+ const match = filterFn ?? defaultFilter
28
+
29
+ const q = query()
30
+ const filtered = q ? items.filter(item => match(q, item)) : items
31
+
32
+ let cursor = listCursor()
33
+ if (cursor >= filtered.length) cursor = Math.max(0, filtered.length - 1)
34
+ if (cursor !== listCursor()) setListCursor(cursor)
35
+
36
+ function updateText(v, c) {
37
+ setQuery(v)
38
+ setTextCursor(c)
39
+ setListCursor(0)
40
+ if (onChange) onChange(v)
41
+ }
42
+
43
+ useInput((event) => {
44
+ if (!focused) return
45
+
46
+ const { key, raw, ctrl } = event
47
+ const len = filtered.length
48
+
49
+ if (key === 'up' || (ctrl && raw === '\x10')) {
50
+ if (len > 0) setListCursor(c => Math.max(0, c - 1))
51
+ event.stopPropagation()
52
+ return
53
+ }
54
+ if (key === 'down' || (ctrl && raw === '\x0e')) {
55
+ if (len > 0) setListCursor(c => Math.min(len - 1, c + 1))
56
+ event.stopPropagation()
57
+ return
58
+ }
59
+ if (ctrl && key === 'u') { if (len > 0) setListCursor(c => Math.max(0, c - 5)); event.stopPropagation(); return }
60
+ if (ctrl && key === 'd') { if (len > 0) setListCursor(c => Math.min(len - 1, c + 5)); event.stopPropagation(); return }
61
+ if (ctrl && key === 'b') { if (len > 0) setListCursor(c => Math.max(0, c - 10)); event.stopPropagation(); return }
62
+ if (ctrl && key === 'f') { if (len > 0) setListCursor(c => Math.min(len - 1, c + 10)); event.stopPropagation(); return }
63
+ if (key === 'pageup') { if (len > 0) setListCursor(c => Math.max(0, c - 10)); event.stopPropagation(); return }
64
+ if (key === 'pagedown') { if (len > 0) setListCursor(c => Math.min(len - 1, c + 10)); event.stopPropagation(); return }
65
+
66
+ if (key === 'return') {
67
+ if (filtered.length > 0 && onSelect) {
68
+ onSelect(filtered[cursor])
69
+ if (clearOnSelect) updateText('', 0)
70
+ event.stopPropagation()
71
+ }
72
+ return
73
+ }
74
+
75
+ if (key === 'escape') {
76
+ if (onCancel) {
77
+ onCancel()
78
+ event.stopPropagation()
79
+ }
80
+ return
81
+ }
82
+
83
+ resetBlink()
84
+ const v = query()
85
+ const c = textCursor()
86
+
87
+ if (key === 'backspace') {
88
+ if (c > 0) updateText(v.slice(0, c - 1) + v.slice(c), c - 1)
89
+ event.stopPropagation()
90
+ return
91
+ }
92
+
93
+ if (key === 'delete') {
94
+ if (c < v.length) updateText(v.slice(0, c) + v.slice(c + 1), c)
95
+ event.stopPropagation()
96
+ return
97
+ }
98
+
99
+ if (key === 'left') { setTextCursor(Math.max(0, c - 1)); event.stopPropagation(); return }
100
+ if (key === 'right') { setTextCursor(Math.min(v.length, c + 1)); event.stopPropagation(); return }
101
+
102
+ if (key === 'home' || (ctrl && raw === '\x01')) { setTextCursor(0); event.stopPropagation(); return }
103
+ if (key === 'end' || (ctrl && raw === '\x05')) { setTextCursor(v.length); event.stopPropagation(); return }
104
+
105
+ if (ctrl && raw === '\x15') { updateText(v.slice(c), 0); event.stopPropagation(); return }
106
+ if (ctrl && raw === '\x0b') { updateText(v.slice(0, c), c); event.stopPropagation(); return }
107
+
108
+ if (ctrl && raw === '\x17') {
109
+ const before = v.slice(0, c)
110
+ const after = v.slice(c)
111
+ const trimmed = before.replace(/\S+\s*$/, '')
112
+ updateText(trimmed + after, trimmed.length)
113
+ event.stopPropagation()
114
+ return
115
+ }
116
+
117
+ if (!ctrl && raw.length === 1 && raw >= ' ') {
118
+ updateText(v.slice(0, c) + raw + v.slice(c), c + raw.length)
119
+ event.stopPropagation()
120
+ }
121
+ })
122
+
123
+ // text input rendering
124
+ const v = query()
125
+ const tc = textCursor()
126
+ const w = layout.width || 0
127
+ const cs = cursorStyle()
128
+
129
+ let inputEl
130
+ if (!v && placeholder && !focused) {
131
+ inputEl = jsx('text', { style: { color: 'gray' }, children: placeholder })
132
+ } else if (!v && placeholder && focused) {
133
+ inputEl = jsxs('box', {
134
+ style: { flexDirection: 'row', height: 1, minHeight: 1 },
135
+ children: [
136
+ jsx('text', { style: cs ? { ...cs, color: cs.color ?? 'gray' } : { inverse: true, color: 'gray' }, children: placeholder[0] }),
137
+ placeholder.length > 1 && jsx('text', { style: { color: 'gray' }, children: placeholder.slice(1) }),
138
+ ],
139
+ })
140
+ } else {
141
+ const contentWidth = v.length + 1
142
+ const needsScroll = w > 0 && contentWidth > w
143
+
144
+ if (!needsScroll) {
145
+ const cursorChar = v[tc] || ' '
146
+ inputEl = jsxs('box', {
147
+ style: { flexDirection: 'row', height: 1, minHeight: 1 },
148
+ children: [
149
+ v.slice(0, tc) && jsx('text', { children: v.slice(0, tc) }),
150
+ jsx('text', { style: cs ?? {}, children: cursorChar }),
151
+ v.slice(tc + 1) && jsx('text', { children: v.slice(tc + 1) }),
152
+ ],
153
+ })
154
+ } else {
155
+ let scrollStart = 0
156
+ if (tc >= w) scrollStart = tc - w + 1
157
+ const visible = v.slice(scrollStart, scrollStart + w)
158
+ const cursorInView = tc - scrollStart
159
+ const before = visible.slice(0, cursorInView)
160
+ const cursorChar = visible[cursorInView] || ' '
161
+ const after = visible.slice(cursorInView + 1)
162
+
163
+ inputEl = jsxs('box', {
164
+ style: { flexDirection: 'row', height: 1, minHeight: 1 },
165
+ children: [
166
+ before && jsx('text', { children: before }),
167
+ jsx('text', { style: cs ?? {}, children: cursorChar }),
168
+ after && jsx('text', { children: after }),
169
+ ],
170
+ })
171
+ }
172
+ }
173
+
174
+ const defaultRenderItem = (item, { selected: isCursor, focused: isFocused }) => {
175
+ const label = typeof item === 'string' ? item : (item.label ?? item.name ?? String(item))
176
+ return jsx('box', {
177
+ style: { bg: isCursor ? (isFocused ? s.cursorBg : 'gray') : null },
178
+ children: jsx('text', {
179
+ style: { color: isCursor ? s.cursorTextColor : s.color },
180
+ children: ` ${label}`,
181
+ }),
182
+ })
183
+ }
184
+
185
+ const listRenderItem = renderItem ?? defaultRenderItem
186
+
187
+ const list = jsx(List, {
188
+ items: filtered,
189
+ selected: cursor,
190
+ onSelect: setListCursor,
191
+ focused,
192
+ interactive: false,
193
+ itemHeight,
194
+ gap: itemGap,
195
+ scrollbar,
196
+ scrolloff,
197
+ renderItem: listRenderItem,
198
+ })
199
+
200
+ return jsxs('box', {
201
+ style: { flexDirection: 'column', flexGrow: 1, gap },
202
+ children: [inputEl, list],
203
+ })
204
+ }
package/src/renderer.js CHANGED
@@ -336,16 +336,39 @@ function paintTree(node, buf, clip, offset, prevBuf) {
336
336
  }
337
337
  }
338
338
 
339
- function extractText(node) {
339
+ function extractText(node, parentCtx) {
340
340
  if (node == null || node === true || node === false) return ''
341
341
  if (typeof node === 'string') return node
342
342
  if (typeof node === 'number') return String(node)
343
+
343
344
  const children = node.props?.children
344
345
  if (children == null || children === true || children === false) return ''
345
- if (typeof children === 'string') return children
346
- if (typeof children === 'number') return String(children)
347
- if (Array.isArray(children)) return children.map(c => extractText(c)).join('')
348
- return ''
346
+ if (typeof children === 'string' && !node.props?.style) return children
347
+ if (typeof children === 'number' && !node.props?.style) return String(children)
348
+
349
+ const style = node.props?.style
350
+ const ownAttrs = style ? resolveAttrs(style) : 0
351
+ const hasOwnStyle = style && (style.color != null || style.bg != null || ownAttrs)
352
+
353
+ const myCtx = hasOwnStyle ? {
354
+ fg: style.color ?? parentCtx?.fg ?? null,
355
+ bg: style.bg ?? parentCtx?.bg ?? null,
356
+ attrs: ownAttrs || parentCtx?.attrs || 0,
357
+ } : (parentCtx || null)
358
+
359
+ let inner
360
+ if (typeof children === 'string') inner = children
361
+ else if (typeof children === 'number') inner = String(children)
362
+ else if (Array.isArray(children)) inner = children.map(c => extractText(c, myCtx)).join('')
363
+ else inner = ''
364
+
365
+ if (parentCtx !== undefined && hasOwnStyle) {
366
+ const prefix = ansi.sgr(myCtx.fg, myCtx.bg, myCtx.attrs)
367
+ const suffix = parentCtx ? ansi.sgr(parentCtx.fg, parentCtx.bg, parentCtx.attrs) : ansi.sgrReset
368
+ return prefix + inner + suffix
369
+ }
370
+
371
+ return inner
349
372
  }
350
373
 
351
374
  function flattenChildren(children) {
@@ -747,5 +770,5 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
747
770
 
748
771
  ctx.repaint = repaint
749
772
 
750
- return { unmount, repaint }
773
+ return { unmount, repaint, getBuffer: () => prev }
751
774
  }
package/src/table.js CHANGED
@@ -4,7 +4,7 @@ import { List } from './list.js'
4
4
 
5
5
  const DEFAULT_SEP = { left: '', fill: '\u2500', right: '' }
6
6
 
7
- export function Table({ columns, data, selected, onSelect, focused = true, separator = false, separatorChars, renderItem, scrollbar, stickyHeader = false }) {
7
+ export function Table({ columns, data, selected, onSelect, focused = true, separator = false, separatorChars, renderItem, scrollbar, stickyHeader = false, gap = 0, itemHeight = 1, scrolloff = 2 }) {
8
8
  const { accent = 'cyan' } = useTheme()
9
9
 
10
10
  const headerRow = jsxs('box', {
@@ -15,9 +15,7 @@ export function Table({ columns, data, selected, onSelect, focused = true, separ
15
15
  style: {
16
16
  width: col.width,
17
17
  flexGrow: col.flexGrow,
18
- color: 'gray',
19
18
  dim: true,
20
- bold: true,
21
19
  overflow: 'truncate',
22
20
  paddingX: col.paddingX ?? 1,
23
21
  },
@@ -26,24 +24,22 @@ export function Table({ columns, data, selected, onSelect, focused = true, separ
26
24
  ),
27
25
  })
28
26
 
27
+ const sepRow = separator
28
+ ? (() => {
29
+ const s = { ...DEFAULT_SEP, ...separatorChars }
30
+ return jsxs('box', {
31
+ style: { flexDirection: 'row' },
32
+ children: [
33
+ jsx('text', { style: { dim: true }, children: s.left }),
34
+ jsx('text', { style: { dim: true, flexGrow: 1, overflow: 'nowrap' }, children: s.fill.repeat(1000) }),
35
+ jsx('text', { style: { dim: true }, children: s.right }),
36
+ ],
37
+ })
38
+ })()
39
+ : null
40
+
29
41
  const header = separator
30
- ? jsxs('box', {
31
- style: { flexDirection: 'column' },
32
- children: [
33
- headerRow,
34
- (() => {
35
- const s = { ...DEFAULT_SEP, ...separatorChars }
36
- return jsxs('box', {
37
- style: { flexDirection: 'row' },
38
- children: [
39
- jsx('text', { style: { color: 'gray', dim: true }, children: s.left }),
40
- jsx('text', { style: { color: 'gray', dim: true, flexGrow: 1, overflow: 'nowrap' }, children: s.fill.repeat(200) }),
41
- jsx('text', { style: { color: 'gray', dim: true }, children: s.right }),
42
- ],
43
- })
44
- })(),
45
- ],
46
- })
42
+ ? jsxs('box', { style: { flexDirection: 'column' }, children: [headerRow, sepRow] })
47
43
  : headerRow
48
44
 
49
45
  const defaultRenderItem = (row, { selected: isSel }) =>
@@ -64,15 +60,37 @@ export function Table({ columns, data, selected, onSelect, focused = true, separ
64
60
  ),
65
61
  })
66
62
 
67
- return jsx(List, {
63
+ const list = jsx(List, {
68
64
  items: data,
69
65
  selected,
70
66
  onSelect,
71
67
  focused,
72
- header,
73
- headerHeight: separator ? 2 : 1,
74
68
  scrollbar,
75
- stickyHeader,
69
+ gap,
70
+ itemHeight,
71
+ scrolloff,
76
72
  renderItem: renderItem || defaultRenderItem,
77
73
  })
74
+
75
+ if (!stickyHeader) {
76
+ return jsx(List, {
77
+ items: data,
78
+ selected,
79
+ onSelect,
80
+ focused,
81
+ header,
82
+ headerHeight: separator ? 2 : 1,
83
+ scrollbar,
84
+ stickyHeader: true,
85
+ gap,
86
+ itemHeight,
87
+ scrolloff,
88
+ renderItem: renderItem || defaultRenderItem,
89
+ })
90
+ }
91
+
92
+ return jsxs('box', {
93
+ style: { flexDirection: 'column', flexGrow: 1 },
94
+ children: [header, list],
95
+ })
78
96
  }
package/src/tabs.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { jsx, jsxs } from '../jsx-runtime.js'
2
- import { useInput } from './hooks.js'
2
+ import { useInput, useTheme } from './hooks.js'
3
3
 
4
4
  export function Tabs({ items, selected, onSelect, focused = true }) {
5
+ const { accent = 'cyan' } = useTheme()
6
+
5
7
  useInput(({ key }) => {
6
8
  if (!focused) return
7
9
 
@@ -17,10 +19,15 @@ export function Tabs({ items, selected, onSelect, focused = true }) {
17
19
 
18
20
  const children = items.map(item => {
19
21
  const isSelected = item === selected
20
- return jsx('text', {
21
- style: isSelected ? { inverse: true, bold: true } : { color: 'gray' },
22
- children: ` ${item} `,
23
- })
22
+ let style
23
+ if (isSelected && focused) {
24
+ style = { bg: accent, color: 'black', bold: true }
25
+ } else if (isSelected) {
26
+ style = { inverse: true, bold: true }
27
+ } else {
28
+ style = { color: 'gray' }
29
+ }
30
+ return jsx('text', { style, children: ` ${item} ` })
24
31
  })
25
32
 
26
33
  return jsxs('box', {