@trendr/core 0.2.9 → 0.2.10

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/index.js CHANGED
@@ -21,6 +21,7 @@ export { ScrollBox } from './src/scroll-box.js'
21
21
  export { SplitPane } from './src/split-pane.js'
22
22
  export { MillerNav } from './src/miller-nav.js'
23
23
  export { MenuBar } from './src/menubar.js'
24
+ export { Menu } from './src/menu.js'
24
25
  export { Task } from './src/task.js'
25
26
  export { Shimmer } from './src/shimmer.js'
26
27
  export { useFocus, useFocusTrap } from './src/focus.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trendr/core",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "direct-mode TUI renderer with JSX, signals, and per-cell diffing",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/menu.js ADDED
@@ -0,0 +1,87 @@
1
+ import { jsx } from '../jsx-runtime.js'
2
+ import { createSignal } from './signal.js'
3
+ import { useInput, useTheme } from './hooks.js'
4
+ import { List } from './list.js'
5
+
6
+ // a windowed single-select menu. shows at most maxVisible rows and scrolls
7
+ // with scrolloff as the active item moves. it carries no text input of its
8
+ // own, so it pairs with an external composer: render it after that input and,
9
+ // while focused, it intercepts up/down/enter before the input sees them
10
+ export function Menu({
11
+ items,
12
+ selected: selectedProp,
13
+ onSelect,
14
+ onSubmit,
15
+ onCancel,
16
+ focused = true,
17
+ maxVisible = 5,
18
+ scrolloff = 2,
19
+ itemHeight = 1,
20
+ gap = 0,
21
+ renderItem,
22
+ arrow = '›',
23
+ }) {
24
+ const { accent = 'cyan' } = useTheme()
25
+ const [internal, setInternal] = createSignal(0)
26
+
27
+ const len = items.length
28
+ const raw = selectedProp ?? internal()
29
+ const selected = Math.min(Math.max(0, raw), Math.max(0, len - 1))
30
+ const setSelected = onSelect ?? setInternal
31
+
32
+ if (selected !== raw && len > 0) setSelected(selected)
33
+
34
+ useInput((event) => {
35
+ if (!focused || len === 0) return
36
+ const { key, ctrl, shift, meta } = event
37
+
38
+ if (key === 'up' || (ctrl && key === 'p')) {
39
+ setSelected(Math.max(0, selected - 1))
40
+ event.stopPropagation()
41
+ } else if (key === 'down' || (ctrl && key === 'n')) {
42
+ setSelected(Math.min(len - 1, selected + 1))
43
+ event.stopPropagation()
44
+ } else if (key === 'return' && !shift && !meta) {
45
+ if (onSubmit) onSubmit(items[selected], selected)
46
+ event.stopPropagation()
47
+ } else if (key === 'escape') {
48
+ if (onCancel) {
49
+ onCancel()
50
+ event.stopPropagation()
51
+ }
52
+ }
53
+ })
54
+
55
+ if (len === 0) return jsx('box', { style: { height: 0 } })
56
+
57
+ const visible = Math.min(maxVisible, len)
58
+ const height = visible * itemHeight + Math.max(0, visible - 1) * gap
59
+
60
+ const defaultRender = (item, { active }) => {
61
+ const label = typeof item === 'string' ? item : (item.label ?? item.name ?? String(item))
62
+ return jsx('box', {
63
+ style: { flexDirection: 'row' },
64
+ children: [
65
+ jsx('text', { style: { color: active ? accent : null }, children: active ? `${arrow} ` : ' ' }),
66
+ jsx('text', { style: { color: active ? accent : 'gray' }, children: label }),
67
+ ],
68
+ })
69
+ }
70
+
71
+ const render = renderItem ?? defaultRender
72
+
73
+ return jsx('box', {
74
+ style: { height, minHeight: height },
75
+ children: jsx(List, {
76
+ items,
77
+ selected,
78
+ onSelect: setSelected,
79
+ focused: false,
80
+ interactive: false,
81
+ itemHeight,
82
+ gap,
83
+ scrolloff,
84
+ renderItem: (item, ctx) => render(item, { active: ctx.selected, index: ctx.index }),
85
+ }),
86
+ })
87
+ }
package/src/renderer.js CHANGED
@@ -666,6 +666,29 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
666
666
  let prevLiveLines = 0
667
667
  let prevLiveText = null
668
668
 
669
+ // mirror per-instance layout (incl. scroll contentHeight/childHeights) back
670
+ // onto each instance so useLayout() consumers like List/Menu can window.
671
+ // returns whether anything changed, so the caller can re-resolve once and let
672
+ // those components observe the freshly measured values
673
+ function syncInstanceLayouts() {
674
+ let changed = false
675
+ for (const inst of instances.values()) {
676
+ const rect = inst.node?._availableRect ?? inst.node?._layout
677
+ if (!rect) continue
678
+ const ch = findScrollContentHeight(inst.node)
679
+ if (!inst.layout) inst.layout = { x: 0, y: 0, width: 0, height: 0 }
680
+ const p = inst.layout
681
+ if (p.width !== rect.width || p.height !== rect.height || p.contentHeight !== ch) changed = true
682
+ p.x = rect.x
683
+ p.y = rect.y
684
+ p.width = rect.width
685
+ p.height = rect.height
686
+ p.contentHeight = ch
687
+ p.childHeights = findScrollChildHeights(inst.node)
688
+ }
689
+ return changed
690
+ }
691
+
669
692
  function inlineFrame() {
670
693
  const prevCtx = activeContext
671
694
  activeContext = ctx
@@ -674,7 +697,7 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
674
697
  const counters = new Map()
675
698
  const visited = new Set()
676
699
  const element = { type: rootComponent, props: {}, key: null }
677
- const tree = resolveForFrame(element, null, instances, counters, visited, '')
700
+ let tree = resolveForFrame(element, null, instances, counters, visited, '')
678
701
 
679
702
  const sb = findScrollback(tree)
680
703
  const items = sb?.props?.items ?? []
@@ -691,9 +714,18 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
691
714
  // which we can't un-print, so just resync the counter
692
715
  flushedCount = items.length
693
716
 
694
- const liveHeight = Math.min(height, Math.max(1, intrinsicHeight(tree, width, height)))
717
+ let liveHeight = Math.min(height, Math.max(1, intrinsicHeight(tree, width, height)))
695
718
  computeLayout(tree, { x: 0, y: 0, width, height: liveHeight })
696
719
 
720
+ if (syncInstanceLayouts()) {
721
+ counters.clear()
722
+ visited.clear()
723
+ tree = resolveForFrame(element, null, instances, counters, visited, '')
724
+ liveHeight = Math.min(height, Math.max(1, intrinsicHeight(tree, width, height)))
725
+ computeLayout(tree, { x: 0, y: 0, width, height: liveHeight })
726
+ syncInstanceLayouts()
727
+ }
728
+
697
729
  for (const [key, inst] of instances) {
698
730
  if (!visited.has(key)) {
699
731
  disposeScope(inst.scope)