@trendr/core 0.2.11 → 0.4.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/scroll-box.js CHANGED
@@ -3,17 +3,22 @@ import { createSignal } from './signal.js'
3
3
  import { useInput, useMouse, useLayout, useTheme, useScrollDrag } from './hooks.js'
4
4
 
5
5
  export function ScrollBox({ children, focused = true, scrollOffset: offsetProp, onScroll, scrollbar = false, gap = 0, thumbChar = '\u2588', trackChar = '\u2502', style: userStyle }) {
6
- const { accent = 'cyan' } = useTheme()
6
+ const { accent = 'cyan', muted = 'gray' } = useTheme()
7
7
  const [offsetInternal, setOffsetInternal] = createSignal(0)
8
8
  const layout = useLayout()
9
9
 
10
10
  const offset = offsetProp ?? offsetInternal()
11
- const setOffset = onScroll ?? setOffsetInternal
12
11
 
13
12
  const visibleH = layout.height
14
13
  const contentH = layout.contentHeight ?? 0
15
14
  const maxOffset = Math.max(0, contentH - visibleH)
16
15
 
16
+ const setOffset = (v) => {
17
+ const next = Math.max(0, Math.min(maxOffset, v))
18
+ if (onScroll) onScroll(next, { atBottom: next >= maxOffset, maxOffset })
19
+ else setOffsetInternal(next)
20
+ }
21
+
17
22
  const clamp = (v) => Math.max(0, Math.min(maxOffset, v))
18
23
  const clamped = clamp(offset)
19
24
 
@@ -24,24 +29,25 @@ export function ScrollBox({ children, focused = true, scrollOffset: offsetProp,
24
29
  const pageH = visibleH || 10
25
30
  const half = Math.max(1, Math.floor(pageH / 2))
26
31
 
27
- if (key === 'up' || key === 'k') setOffset(clamp(offset - 1))
28
- else if (key === 'down' || key === 'j') setOffset(clamp(offset + 1))
29
- else if (key === 'pageup' || (ctrl && key === 'b')) setOffset(clamp(offset - pageH))
30
- else if (key === 'pagedown' || (ctrl && key === 'f')) setOffset(clamp(offset + pageH))
31
- else if (ctrl && key === 'u') setOffset(clamp(offset - half))
32
- else if (ctrl && key === 'd') setOffset(clamp(offset + half))
32
+ if (key === 'up' || key === 'k') setOffset(clamp(clamped - 1))
33
+ else if (key === 'down' || key === 'j') setOffset(clamp(clamped + 1))
34
+ else if (key === 'pageup' || (ctrl && key === 'b')) setOffset(clamp(clamped - pageH))
35
+ else if (key === 'pagedown' || (ctrl && key === 'f')) setOffset(clamp(clamped + pageH))
36
+ else if (ctrl && key === 'u') setOffset(clamp(clamped - half))
37
+ else if (ctrl && key === 'd') setOffset(clamp(clamped + half))
33
38
  else if (key === 'home' || key === 'g') setOffset(0)
34
39
  else if (key === 'end' || key === 'G') setOffset(maxOffset)
35
40
  })
36
41
 
37
42
  useMouse((event) => {
38
- if (!focused) return
43
+ // wheel scrolls by position, not keyboard focus - whatever is under the cursor
39
44
  if (event.action !== 'scroll') return
40
45
  if (contentH <= visibleH) return
41
46
  const { x, y } = event
42
47
  if (x < layout.x || x >= layout.x + layout.width || y < layout.y || y >= layout.y + layout.height) return
43
- if (event.direction === 'up') setOffset(clamp(offset - 3))
44
- else setOffset(clamp(offset + 3))
48
+ if (event.direction !== 'up' && event.direction !== 'down') return
49
+ if (event.direction === 'up') setOffset(clamp(clamped - 3))
50
+ else setOffset(clamp(clamped + 3))
45
51
  event.stopPropagation()
46
52
  })
47
53
 
@@ -87,7 +93,7 @@ export function ScrollBox({ children, focused = true, scrollOffset: offsetProp,
87
93
  barChildren.push(
88
94
  jsx('text', {
89
95
  key: i,
90
- style: { color: isThumb ? (focused ? accent : 'gray') : 'gray', dim: !isThumb },
96
+ style: { color: isThumb ? (focused ? accent : muted) : muted, dim: !isThumb },
91
97
  children: isThumb ? thumbChar : trackChar,
92
98
  })
93
99
  )
@@ -4,7 +4,7 @@ import { useInput, useMouse, useLayout, useTheme, useScrollDrag } from './hooks.
4
4
  import { wordWrap } from './wrap.js'
5
5
 
6
6
  export function ScrollableText({ content = '', focused = true, scrollOffset: offsetProp, onScroll, width: widthProp, scrollbar = false, wrap = true, thumbChar = '\u2588', trackChar = '\u2502' }) {
7
- const { accent = 'cyan' } = useTheme()
7
+ const { accent = 'cyan', muted = 'gray' } = useTheme()
8
8
  const [offsetInternal, setOffsetInternal] = createSignal(0)
9
9
  const layout = useLayout()
10
10
 
@@ -43,6 +43,7 @@ export function ScrollableText({ content = '', focused = true, scrollOffset: off
43
43
  if (lines.length <= (h || 1)) return
44
44
  const { x, y } = event
45
45
  if (x < layout.x || x >= layout.x + layout.width || y < layout.y || y >= layout.y + layout.height) return
46
+ if (event.direction !== 'up' && event.direction !== 'down') return
46
47
  if (event.direction === 'up') setOffset(clamp(clamped - 3))
47
48
  else setOffset(clamp(clamped + 3))
48
49
  event.stopPropagation()
@@ -81,7 +82,7 @@ export function ScrollableText({ content = '', focused = true, scrollOffset: off
81
82
  const children = visible.map((line, i) => {
82
83
  const isThumb = i >= thumbStart && i < thumbStart + thumbH
83
84
  const barChar = isThumb ? thumbChar : trackChar
84
- const barColor = isThumb ? (focused ? accent : 'gray') : 'gray'
85
+ const barColor = isThumb ? (focused ? accent : muted) : muted
85
86
 
86
87
  return jsx('box', {
87
88
  key: i,
package/src/select.js CHANGED
@@ -1,153 +1,22 @@
1
1
  import { jsx } from '../jsx-runtime.js'
2
2
  import { createSignal } from './signal.js'
3
3
  import { useInput, useMouse, useLayout, useTheme } from './hooks.js'
4
- import { registerOverlay, getInstanceLayout, getContext, registerHook } from './renderer.js'
4
+ import { getInstanceLayout, getContext } from './renderer.js'
5
+ import { Dropdown, followCursor, placeDropdown, overlayDropdown } from './dropdown.js'
5
6
 
6
- function SelectDropdown({ items, cursor, scroll, visibleCount, onSelect, onClose, onCursorChange, renderItem, style: s, accent, dropWidth }) {
7
- const layout = useLayout()
8
- const drag = registerHook(() => ({ active: false, startY: 0, startCursor: 0 }))
9
-
10
- useMouse((event) => {
11
- if (event.action === 'release') {
12
- if (drag.active) drag.active = false
13
- return
14
- }
7
+ const itemLabel = (item) => String(item?.label ?? item)
15
8
 
16
- if (event.action === 'drag' && drag.active) {
17
- const thumbH = Math.max(1, Math.round((visibleCount / items.length) * visibleCount))
18
- const dy = event.y - drag.startY
19
- const travel = Math.max(1, visibleCount - thumbH)
20
- const ratio = (items.length - 1) / travel
21
- const idx = Math.max(0, Math.min(items.length - 1, Math.round(drag.startCursor + dy * ratio)))
22
- onCursorChange(idx)
23
- event.stopPropagation()
24
- return
25
- }
26
-
27
- // layout not yet computed - consume event but don't act
28
- if (layout.width === 0 || layout.height === 0) {
29
- event.stopPropagation()
30
- return
31
- }
32
-
33
- const { x, y } = event
34
- const boxRight = layout.x + dropWidth
35
- const inside = x >= layout.x && x < boxRight && y >= layout.y && y < layout.y + layout.height
36
-
37
- if (event.action === 'scroll') {
38
- if (!inside) return
39
- if (event.direction === 'up') onCursorChange(Math.max(0, cursor - 1))
40
- else onCursorChange(Math.min(items.length - 1, cursor + 1))
41
- event.stopPropagation()
42
- return
43
- }
44
-
45
- if (event.action !== 'press' || event.button !== 'left') return
46
-
47
- if (!inside) {
48
- onClose()
49
- event.stopPropagation()
50
- return
51
- }
52
-
53
- const scrollable = items.length > visibleCount
54
- if (scrollable && x >= boxRight - 4) {
55
- const maxSc = items.length - visibleCount
56
- const thumbH = Math.max(1, Math.round((visibleCount / items.length) * visibleCount))
57
- const thumbStart = maxSc > 0 ? Math.round((scroll / maxSc) * (visibleCount - thumbH)) : 0
58
- const barY = layout.y + 1 + thumbStart
59
- if (y >= barY && y < barY + thumbH) {
60
- drag.active = true
61
- drag.startY = y
62
- drag.startCursor = cursor
63
- }
64
- event.stopPropagation()
65
- return
66
- }
67
-
68
- const relY = y - layout.y - 1
69
- if (relY >= 0 && relY < visibleCount) {
70
- const clickedIdx = relY + scroll
71
- if (clickedIdx >= 0 && clickedIdx < items.length) {
72
- onSelect(items[clickedIdx])
73
- event.stopPropagation()
74
- }
75
- }
76
- })
77
-
78
- const scrollable = items.length > visibleCount
79
- const visible = items.slice(scroll, scroll + visibleCount)
80
- const thumbH = scrollable ? Math.max(1, Math.round((visibleCount / items.length) * visibleCount)) : 0
81
- const maxSc = items.length - visibleCount
82
- const thumbStart = scrollable && maxSc > 0 ? Math.round((scroll / maxSc) * (visibleCount - thumbH)) : 0
83
- const maxLen = items.reduce((m, v) => Math.max(m, v.length), 0)
84
-
85
- const dropdownChildren = visible.map((item, vi) => {
86
- const i = vi + scroll
87
- const isCursor = i === cursor
88
-
89
- const row = (content) => {
90
- if (!scrollable) return content
91
- const barIsThumb = vi >= thumbStart && vi < thumbStart + thumbH
92
- return jsx('box', {
93
- key: i,
94
- style: { flexDirection: 'row' },
95
- children: [
96
- content,
97
- jsx('text', {
98
- style: { color: barIsThumb ? accent : 'gray', dim: !barIsThumb },
99
- children: ' ' + (barIsThumb ? '\u2588' : '\u2502'),
100
- }),
101
- ],
102
- })
103
- }
104
-
105
- if (renderItem) {
106
- const content = jsx('box', {
107
- key: scrollable ? undefined : i,
108
- style: { bg: isCursor ? s.cursorBg : s.bg, flexGrow: 1 },
109
- children: renderItem(item, { selected: isCursor, index: i }),
110
- })
111
- return row(content)
112
- }
113
-
114
- const content = jsx('box', {
115
- key: scrollable ? undefined : i,
116
- style: { bg: isCursor ? s.cursorBg : s.bg, paddingX: 1, flexGrow: 1 },
117
- children: jsx('text', {
118
- style: { color: isCursor ? s.cursorTextColor : s.color },
119
- children: item,
120
- }),
121
- })
122
- return row(content)
123
- })
124
-
125
- const dropdownH = visibleCount + 2
126
-
127
- return jsx('box', {
128
- style: {
129
- flexDirection: 'column',
130
- border: s.border,
131
- borderColor: s.borderColor,
132
- height: dropdownH,
133
- width: maxLen + 4 + (scrollable ? 2 : 0),
134
- bg: s.bg,
135
- },
136
- children: dropdownChildren,
137
- })
138
- }
139
-
140
- export function Select({ items, selected, onSelect, focused = false, overlay = false, maxVisible = 10, placeholder = 'select...', renderItem, style: userStyle, openIcon = '\u25b2', closedIcon = '\u25bc' }) {
141
- const { accent = 'cyan' } = useTheme()
9
+ export function Select({ items = [], selected, onChange, onFocus, focused = false, overlay = false, maxVisible = 10, placeholder = 'select...', renderItem, style: userStyle, openIcon = '▲', closedIcon = '▼' }) {
10
+ const { accent = 'cyan', accentText = 'black', muted = 'gray' } = useTheme()
142
11
  const defaults = {
143
12
  border: 'single',
144
13
  borderColor: accent,
145
14
  bg: null,
146
15
  cursorBg: accent,
147
- cursorTextColor: 'black',
16
+ cursorTextColor: accentText,
148
17
  color: null,
149
18
  focusedBg: accent,
150
- focusedColor: 'black',
19
+ focusedColor: accentText,
151
20
  }
152
21
  const s = { ...defaults, ...userStyle }
153
22
 
@@ -172,7 +41,11 @@ export function Select({ items, selected, onSelect, focused = false, overlay = f
172
41
  const len = items.length
173
42
  if (key === 'up' || key === 'k') { setCursor(c => Math.max(0, c - 1)); event.stopPropagation() }
174
43
  else if (key === 'down' || key === 'j') { setCursor(c => Math.min(len - 1, c + 1)); event.stopPropagation() }
175
- else if (key === 'return' || key === 'space') { onSelect?.(items[cursor()]); setOpen(false); event.stopPropagation() }
44
+ else if (key === 'return' || key === 'space') {
45
+ if (len > 0) onChange?.(items[Math.min(cursor(), len - 1)])
46
+ setOpen(false)
47
+ event.stopPropagation()
48
+ }
176
49
  else if (key === 'escape') { setOpen(false); event.stopPropagation() }
177
50
  })
178
51
 
@@ -181,6 +54,7 @@ export function Select({ items, selected, onSelect, focused = false, overlay = f
181
54
  useMouse((event) => {
182
55
  if (event.action === 'scroll') {
183
56
  if (!focused || !open()) return
57
+ if (event.direction !== 'up' && event.direction !== 'down') return
184
58
  const len = items.length
185
59
  if (event.direction === 'up') setCursor(c => Math.max(0, c - 1))
186
60
  else setCursor(c => Math.min(len - 1, c + 1))
@@ -193,6 +67,12 @@ export function Select({ items, selected, onSelect, focused = false, overlay = f
193
67
  const onCollapsed = x >= layout.x && x < layout.x + layout.width && y === layout.y
194
68
 
195
69
  if (onCollapsed) {
70
+ // an unfocused select would open a keyboard-dead dropdown - only allow
71
+ // click-to-open when focused, or when the app can move focus here
72
+ if (!focused) {
73
+ if (!onFocus) return
74
+ onFocus()
75
+ }
196
76
  if (open()) {
197
77
  setOpen(false)
198
78
  } else {
@@ -204,66 +84,78 @@ export function Select({ items, selected, onSelect, focused = false, overlay = f
204
84
  }
205
85
  })
206
86
 
207
- const display = selected ?? placeholder
87
+ const display = selected != null ? itemLabel(selected) : placeholder
208
88
  const collapsed = jsx('text', {
209
89
  style: {
210
90
  bg: focused ? s.focusedBg : null,
211
- color: focused ? s.focusedColor : (selected ? s.color : 'gray'),
91
+ color: focused ? s.focusedColor : (selected != null ? s.color : muted),
212
92
  bold: focused,
213
93
  },
214
94
  children: `${open() ? openIcon : closedIcon} ${display}`,
215
95
  })
216
96
 
217
- if (!open()) return collapsed
97
+ if (!open() || items.length === 0) return collapsed
218
98
 
219
- let maxRows = Math.min(items.length, maxVisible)
99
+ let visibleCount = Math.min(items.length, maxVisible)
100
+ let direction = 'down'
101
+ let instLayout = null
102
+ let termW = 80
103
+ let termH = 24
220
104
 
221
105
  if (overlay) {
222
- const instLayout = getInstanceLayout()
106
+ instLayout = getInstanceLayout()
223
107
  const ctx = getContext()
224
- const termH = ctx?.stream?.rows ?? 24
225
- const anchorY = instLayout.y + 1
226
- const available = termH - anchorY - 2
227
- if (available > 0) maxRows = Math.min(maxRows, available)
228
- }
229
-
230
- const visibleCount = maxRows
231
-
232
- const cur = cursor()
233
- const sc = scroll()
234
- let newScroll = sc
235
- if (cur < sc) newScroll = cur
236
- else if (cur >= sc + visibleCount) newScroll = cur - visibleCount + 1
237
- newScroll = Math.max(0, Math.min(newScroll, items.length - visibleCount))
238
- if (newScroll !== sc) setScroll(newScroll)
239
-
240
- const handleSelect = (item) => {
241
- onSelect?.(item)
242
- setOpen(false)
108
+ termH = ctx?.stream?.rows ?? 24
109
+ termW = ctx?.stream?.columns ?? 80
110
+ const placed = placeDropdown({ anchorTop: instLayout.y, itemCount: items.length, maxVisible, termH })
111
+ direction = placed.direction
112
+ visibleCount = placed.visibleCount
243
113
  }
244
114
 
245
- const handleClose = () => setOpen(false)
115
+ const cur = Math.min(cursor(), items.length - 1)
116
+ const newScroll = followCursor(cur, scroll(), visibleCount, items.length)
117
+ if (newScroll !== scroll()) setScroll(newScroll)
246
118
 
247
- const maxLen = items.reduce((m, v) => Math.max(m, v.length), 0)
119
+ const maxLen = items.reduce((m, v) => Math.max(m, itemLabel(v).length), 0)
248
120
  const scrollable = items.length > visibleCount
249
121
  const dropWidth = maxLen + 4 + (scrollable ? 2 : 0)
250
122
 
251
- const dropdown = jsx(SelectDropdown, {
123
+ const renderRow = (item, { index, isCursor }) => {
124
+ if (renderItem) {
125
+ return jsx('box', {
126
+ style: { bg: isCursor ? s.cursorBg : s.bg, flexGrow: 1 },
127
+ children: renderItem(item, { selected: isCursor, index }),
128
+ })
129
+ }
130
+ return jsx('box', {
131
+ style: { bg: isCursor ? s.cursorBg : s.bg, paddingX: 1, flexGrow: 1 },
132
+ children: jsx('text', {
133
+ style: { color: isCursor ? s.cursorTextColor : s.color },
134
+ children: itemLabel(item),
135
+ }),
136
+ })
137
+ }
138
+
139
+ const dropdown = jsx(Dropdown, {
252
140
  items,
253
141
  cursor: cur,
254
142
  scroll: newScroll,
255
143
  visibleCount,
256
- onSelect: handleSelect,
257
- onClose: handleClose,
144
+ width: dropWidth,
145
+ onSubmit: (item) => {
146
+ onChange?.(item)
147
+ setOpen(false)
148
+ },
149
+ onClose: () => setOpen(false),
258
150
  onCursorChange: (idx) => setCursor(idx),
259
- renderItem,
260
- style: s,
261
- accent,
262
- dropWidth,
151
+ renderRow,
152
+ style: { border: s.border, borderColor: s.borderColor, bg: s.bg, accent },
263
153
  })
264
154
 
265
155
  if (overlay) {
266
- registerOverlay(dropdown)
156
+ const dropdownH = visibleCount + 2
157
+ const absY = direction === 'up' ? instLayout.y - dropdownH : instLayout.y + 1
158
+ overlayDropdown({ x: instLayout.x, y: absY, termW, termH, dropdown })
267
159
  return collapsed
268
160
  }
269
161
 
@@ -0,0 +1,92 @@
1
+ import { useMouse } from './hooks.js'
2
+ import { getContext, registerHook } from './renderer.js'
3
+ import { osc52Copy } from './ansi.js'
4
+
5
+ function normalize(a, b) {
6
+ const forward = a.y < b.y || (a.y === b.y && a.x <= b.x)
7
+ return forward
8
+ ? { sx: a.x, sy: a.y, ex: b.x, ey: b.y }
9
+ : { sx: b.x, sy: b.y, ex: a.x, ey: a.y }
10
+ }
11
+
12
+ // reads the selected region back out of the painted cell buffer. rows flagged
13
+ // as soft wraps rejoin the previous row with a space, so copied prose comes
14
+ // out as one paragraph regardless of the terminal width; unflagged rows keep
15
+ // their newline, so code copies with its line structure intact. all hard rows
16
+ // are extracted column-aligned and share one dedent, which strips the screen
17
+ // padding common to the block while preserving relative code indentation
18
+ export function extractSelectionText(buf, sel) {
19
+ const rows = []
20
+ const lastY = Math.min(sel.ey, buf.height - 1)
21
+ for (let y = Math.max(0, sel.sy); y <= lastY; y++) {
22
+ const to = y === sel.ey ? Math.min(sel.ex, buf.width - 1) : buf.width - 1
23
+ let text = ''
24
+ for (let x = 0; x <= to; x++) {
25
+ const ch = buf.cells[y * buf.width + x].ch
26
+ if (ch === '') continue
27
+ text += y === sel.sy && x < sel.sx ? ' ' : ch
28
+ }
29
+ rows.push({ text: text.replace(/\s+$/, ''), soft: !!buf.softWrap[y] })
30
+ }
31
+
32
+ let indent = Infinity
33
+ for (const row of rows) {
34
+ if (row.soft || row.text === '') continue
35
+ indent = Math.min(indent, row.text.match(/^ */)[0].length)
36
+ }
37
+ if (indent === Infinity) indent = 0
38
+
39
+ let out = ''
40
+ for (let i = 0; i < rows.length; i++) {
41
+ const row = rows[i]
42
+ if (i === 0) out = row.text.slice(indent)
43
+ else if (row.soft) out += (out === '' || out.endsWith('\n') ? '' : ' ') + row.text.trim()
44
+ else out += '\n' + (row.text === '' ? '' : row.text.slice(indent))
45
+ }
46
+
47
+ return out.replace(/^\n+/, '').replace(/\s+$/, '')
48
+ }
49
+
50
+ // terminal-style click-drag text selection over the painted buffer. while
51
+ // dragging, the covered cells render inverse; on release the text is
52
+ // extracted, written to the system clipboard via OSC 52 (unless copy is
53
+ // false), and passed to onCopy. registered handlers never stop propagation
54
+ // on press, so clicks still reach interactive components underneath
55
+ export function useSelection({ onCopy, copy = true } = {}) {
56
+ const state = registerHook(() => ({ anchor: null, dragging: false }))
57
+ const ctx = getContext()
58
+ if (!ctx) throw new Error('useSelection must be called within a mounted component')
59
+
60
+ useMouse((event) => {
61
+ if (event.action === 'press' && event.button === 'left') {
62
+ state.anchor = { x: event.x, y: event.y }
63
+ state.dragging = false
64
+ if (ctx.selection) {
65
+ ctx.selection = null
66
+ ctx.repaint()
67
+ }
68
+ return
69
+ }
70
+
71
+ if (event.action === 'drag' && state.anchor) {
72
+ state.dragging = true
73
+ ctx.selection = normalize(state.anchor, { x: event.x, y: event.y })
74
+ ctx.repaint()
75
+ return
76
+ }
77
+
78
+ if (event.action === 'release') {
79
+ if (state.dragging && ctx.selection) {
80
+ const text = extractSelectionText(ctx.getPaintBuffer(), ctx.selection)
81
+ ctx.selection = null
82
+ ctx.repaint()
83
+ if (text) {
84
+ if (copy) ctx.stream.write(osc52Copy(text))
85
+ if (onCopy) onCopy(text)
86
+ }
87
+ }
88
+ state.anchor = null
89
+ state.dragging = false
90
+ }
91
+ })
92
+ }
package/src/shimmer.js CHANGED
@@ -28,8 +28,8 @@ function lerpColor(a, b, t) {
28
28
  }
29
29
 
30
30
  export function Shimmer({ children, color, highlight, size = 3, gradient = 3, duration = 1000, delay = 500, reverse = false }) {
31
- const { accent = 'cyan' } = useTheme()
32
- const baseColor = color ?? 'gray'
31
+ const { accent = 'cyan', muted = 'gray' } = useTheme()
32
+ const baseColor = color ?? muted
33
33
  const hlColor = highlight ?? accent
34
34
 
35
35
  const text = typeof children === 'string' ? children : String(children ?? '')
package/src/signal.js CHANGED
@@ -38,10 +38,16 @@ export function createSignalRaw(value) {
38
38
  if (v === value) return
39
39
  value = v
40
40
  if (batchDepth > 0) {
41
- for (const s of subs) pendingEffects.add(s)
41
+ for (const s of subs) {
42
+ if (s.disposed) subs.delete(s)
43
+ else pendingEffects.add(s)
44
+ }
42
45
  } else {
43
46
  const snapshot = [...subs]
44
- for (const s of snapshot) s.run()
47
+ for (const s of snapshot) {
48
+ if (s.disposed) subs.delete(s)
49
+ else s.run()
50
+ }
45
51
  }
46
52
  if (schedulerHook && batchDepth === 0) schedulerHook()
47
53
  }
@@ -56,11 +62,13 @@ export function createSignal(value) {
56
62
  return createSignalRaw(value)
57
63
  }
58
64
 
59
- export function createEffect(fn) {
65
+ export function createEffectRaw(fn) {
60
66
  const effect = {
61
67
  fn,
62
68
  cleanup: null,
69
+ disposed: false,
63
70
  run() {
71
+ if (effect.disposed) return
64
72
  if (effect.cleanup) effect.cleanup()
65
73
  const prev = currentEffect
66
74
  currentEffect = effect
@@ -80,6 +88,13 @@ export function createEffect(fn) {
80
88
  return effect
81
89
  }
82
90
 
91
+ export function createEffect(fn) {
92
+ if (hookRegistrar) {
93
+ return hookRegistrar(() => createEffectRaw(fn))
94
+ }
95
+ return createEffectRaw(fn)
96
+ }
97
+
83
98
  export function createMemo(fn) {
84
99
  const [get, set] = createSignal(undefined)
85
100
  createEffect(() => set(fn()))
@@ -144,7 +159,9 @@ export function createScope(fn) {
144
159
  export function disposeScope(scope) {
145
160
  for (const child of scope.children) disposeScope(child)
146
161
  for (const effect of scope.effects) {
162
+ effect.disposed = true
147
163
  if (effect.cleanup) effect.cleanup()
164
+ effect.cleanup = null
148
165
  }
149
166
  for (const fn of scope.cleanups) fn()
150
167
  scope.effects.length = 0
package/src/table.js CHANGED
@@ -4,8 +4,8 @@ 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, gap = 0, itemHeight = 1, scrolloff = 2, columnGap = 1 }) {
8
- const { accent = 'cyan' } = useTheme()
7
+ export function Table({ columns = [], data, selected, onSelect, focused = true, separator = false, separatorChars, renderItem, scrollbar, stickyHeader = false, gap = 0, itemHeight = 1, scrolloff = 2, columnGap = 1 }) {
8
+ const { accent = 'cyan', accentText = 'black', muted = 'gray' } = useTheme()
9
9
 
10
10
  const headerRow = jsxs('box', {
11
11
  style: { flexDirection: 'row', gap: columnGap },
@@ -44,7 +44,7 @@ export function Table({ columns, data, selected, onSelect, focused = true, separ
44
44
 
45
45
  const defaultRenderItem = (row, { selected: isSel }) =>
46
46
  jsxs('box', {
47
- style: { flexDirection: 'row', gap: columnGap, bg: isSel ? (focused ? accent : 'gray') : null },
47
+ style: { flexDirection: 'row', gap: columnGap, bg: isSel ? (focused ? accent : muted) : null },
48
48
  children: columns.map((col, i) =>
49
49
  jsx('text', {
50
50
  key: i,
@@ -53,44 +53,25 @@ export function Table({ columns, data, selected, onSelect, focused = true, separ
53
53
  flexGrow: col.flexGrow,
54
54
  overflow: 'truncate',
55
55
  paddingX: col.paddingX ?? 1,
56
- color: isSel && focused ? 'black' : (col.color ?? null),
56
+ color: isSel && focused ? accentText : (col.color ?? null),
57
57
  },
58
58
  children: col.render ? col.render(row, isSel) : String(row[col.key] ?? ''),
59
59
  })
60
60
  ),
61
61
  })
62
62
 
63
- const list = jsx(List, {
63
+ return jsx(List, {
64
64
  items: data,
65
65
  selected,
66
66
  onSelect,
67
67
  focused,
68
+ header,
69
+ headerHeight: separator ? 2 : 1,
70
+ stickyHeader,
68
71
  scrollbar,
69
72
  gap,
70
73
  itemHeight,
71
74
  scrolloff,
72
75
  renderItem: renderItem || defaultRenderItem,
73
76
  })
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
- })
96
77
  }
package/src/tabs.js CHANGED
@@ -1,19 +1,22 @@
1
1
  import { jsx, jsxs } from '../jsx-runtime.js'
2
2
  import { useInput, useTheme } from './hooks.js'
3
3
 
4
- export function Tabs({ items, selected, onSelect, focused = true }) {
5
- const { accent = 'cyan' } = useTheme()
4
+ export function Tabs({ items = [], selected, onChange, focused = true }) {
5
+ const { accent = 'cyan', accentText = 'black', muted = 'gray' } = useTheme()
6
6
 
7
- useInput(({ key }) => {
7
+ useInput((event) => {
8
8
  if (!focused) return
9
9
 
10
+ const { key } = event
10
11
  const idx = items.indexOf(selected)
11
12
  if (idx === -1) return
12
13
 
13
14
  if (key === 'left' || key === 'shift-tab') {
14
- onSelect(items[(idx - 1 + items.length) % items.length])
15
+ onChange?.(items[(idx - 1 + items.length) % items.length])
16
+ event.stopPropagation()
15
17
  } else if (key === 'right' || key === 'tab') {
16
- onSelect(items[(idx + 1) % items.length])
18
+ onChange?.(items[(idx + 1) % items.length])
19
+ event.stopPropagation()
17
20
  }
18
21
  })
19
22
 
@@ -21,11 +24,11 @@ export function Tabs({ items, selected, onSelect, focused = true }) {
21
24
  const isSelected = item === selected
22
25
  let style
23
26
  if (isSelected && focused) {
24
- style = { bg: accent, color: 'black', bold: true }
27
+ style = { bg: accent, color: accentText, bold: true }
25
28
  } else if (isSelected) {
26
29
  style = { inverse: true, bold: true }
27
30
  } else {
28
- style = { color: 'gray' }
31
+ style = { color: muted }
29
32
  }
30
33
  return jsx('text', { style, children: ` ${item} ` })
31
34
  })