@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 +1 -0
- package/package.json +1 -1
- package/src/menu.js +87 -0
- package/src/renderer.js +34 -2
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
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
|
-
|
|
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
|
-
|
|
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)
|