@effect-tui/react 0.6.1 → 0.6.3
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/dist/src/components/ListView.d.ts +7 -1
- package/dist/src/components/ListView.d.ts.map +1 -1
- package/dist/src/components/ListView.js +16 -8
- package/dist/src/components/ListView.js.map +1 -1
- package/dist/src/hooks/use-scroll.d.ts +6 -0
- package/dist/src/hooks/use-scroll.d.ts.map +1 -1
- package/dist/src/hooks/use-scroll.js +7 -3
- package/dist/src/hooks/use-scroll.js.map +1 -1
- package/dist/src/test/render-tui.d.ts +1 -1
- package/dist/src/test/render-tui.d.ts.map +1 -1
- package/dist/src/test/render-tui.js +20 -2
- package/dist/src/test/render-tui.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/components/ListView.tsx +24 -7
- package/src/hooks/use-scroll.ts +14 -2
- package/src/test/render-tui.ts +21 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effect-tui/react",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "React bindings for @effect-tui/core",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
"prepublishOnly": "bun run typecheck && bun run build"
|
|
84
84
|
},
|
|
85
85
|
"dependencies": {
|
|
86
|
-
"@effect-tui/core": "^0.6.
|
|
86
|
+
"@effect-tui/core": "^0.6.3",
|
|
87
87
|
"@effect/platform": "^0.94.0",
|
|
88
88
|
"@effect/platform-bun": "^0.87.0",
|
|
89
89
|
"@effect/rpc": "^0.73.0",
|
|
@@ -22,6 +22,12 @@ export interface ListViewProps<T> {
|
|
|
22
22
|
emptyContent?: ReactNode
|
|
23
23
|
/** Number of extra items to render above/below viewport for smooth scrolling (default: 3) */
|
|
24
24
|
overscan?: number
|
|
25
|
+
/** @internal */
|
|
26
|
+
__debugViewportMeasured?: boolean
|
|
27
|
+
/** @internal */
|
|
28
|
+
__debugViewportSize?: number
|
|
29
|
+
/** @internal */
|
|
30
|
+
__debugContentSize?: number
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
/**
|
|
@@ -72,11 +78,19 @@ export function ListView<T>({
|
|
|
72
78
|
showScrollbar = true,
|
|
73
79
|
emptyContent,
|
|
74
80
|
overscan = 3,
|
|
81
|
+
__debugViewportMeasured,
|
|
82
|
+
__debugViewportSize,
|
|
83
|
+
__debugContentSize,
|
|
75
84
|
}: ListViewProps<T>) {
|
|
85
|
+
const totalHeight = items.length * itemHeight
|
|
76
86
|
const { state, scrollProps, scrollToVisible } = useScroll({
|
|
77
87
|
enableKeyboard: false, // Parent handles keyboard for selection
|
|
78
88
|
enableMouseWheel: true, // Free scroll with wheel
|
|
89
|
+
initialViewportSize: __debugViewportSize,
|
|
90
|
+
initialContentSize: __debugContentSize,
|
|
79
91
|
})
|
|
92
|
+
const viewportMeasured = __debugViewportMeasured ?? state.viewportMeasured
|
|
93
|
+
const viewportSize = __debugViewportSize ?? state.viewportSize
|
|
80
94
|
|
|
81
95
|
// Track previous selection to detect changes
|
|
82
96
|
const prevSelectedRef = useRef(selectedIndex)
|
|
@@ -103,13 +117,16 @@ export function ListView<T>({
|
|
|
103
117
|
}
|
|
104
118
|
|
|
105
119
|
// Calculate visible range for virtualization
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
120
|
+
const startIndex = viewportMeasured
|
|
121
|
+
? Math.max(0, Math.floor(state.offset / itemHeight) - overscan)
|
|
122
|
+
: 0
|
|
123
|
+
const endIndex = viewportMeasured
|
|
124
|
+
? Math.min(items.length, Math.ceil((state.offset + viewportSize) / itemHeight) + overscan)
|
|
125
|
+
: items.length
|
|
109
126
|
|
|
110
127
|
// Calculate spacer heights for virtual scrolling
|
|
111
|
-
const topSpacerHeight = startIndex * itemHeight
|
|
112
|
-
const bottomSpacerHeight = Math.max(0, totalHeight - endIndex * itemHeight)
|
|
128
|
+
const topSpacerHeight = viewportMeasured ? startIndex * itemHeight : 0
|
|
129
|
+
const bottomSpacerHeight = viewportMeasured ? Math.max(0, totalHeight - endIndex * itemHeight) : 0
|
|
113
130
|
|
|
114
131
|
// Get visible slice of items
|
|
115
132
|
const visibleItems = items.slice(startIndex, endIndex)
|
|
@@ -118,7 +135,7 @@ export function ListView<T>({
|
|
|
118
135
|
<scroll {...scrollProps} showScrollbar={showScrollbar}>
|
|
119
136
|
<vstack>
|
|
120
137
|
{/* Virtual top spacer */}
|
|
121
|
-
{topSpacerHeight > 0 && <spacer
|
|
138
|
+
{topSpacerHeight > 0 && <spacer minHeight={topSpacerHeight} />}
|
|
122
139
|
|
|
123
140
|
{/* Render only visible items */}
|
|
124
141
|
{visibleItems.map((item, i) => {
|
|
@@ -131,7 +148,7 @@ export function ListView<T>({
|
|
|
131
148
|
})}
|
|
132
149
|
|
|
133
150
|
{/* Virtual bottom spacer */}
|
|
134
|
-
{bottomSpacerHeight > 0 && <spacer
|
|
151
|
+
{bottomSpacerHeight > 0 && <spacer minHeight={bottomSpacerHeight} />}
|
|
135
152
|
</vstack>
|
|
136
153
|
</scroll>
|
|
137
154
|
)
|
package/src/hooks/use-scroll.ts
CHANGED
|
@@ -91,6 +91,8 @@ export interface ScrollState {
|
|
|
91
91
|
maxOffset: number
|
|
92
92
|
/** Viewport height (or width for horizontal) */
|
|
93
93
|
viewportSize: number
|
|
94
|
+
/** Whether viewport size has been measured by the host */
|
|
95
|
+
viewportMeasured: boolean
|
|
94
96
|
/** Total content size */
|
|
95
97
|
contentSize: number
|
|
96
98
|
/** Whether we're at the start edge */
|
|
@@ -102,6 +104,10 @@ export interface ScrollState {
|
|
|
102
104
|
export interface UseScrollOptions {
|
|
103
105
|
/** Scroll axis: "vertical" (default) or "horizontal" */
|
|
104
106
|
axis?: "vertical" | "horizontal"
|
|
107
|
+
/** Initial viewport size override (useful for tests) */
|
|
108
|
+
initialViewportSize?: number
|
|
109
|
+
/** Initial content size override (useful for tests) */
|
|
110
|
+
initialContentSize?: number
|
|
105
111
|
/** Initial scroll offset */
|
|
106
112
|
initialOffset?: number
|
|
107
113
|
/** Enable keyboard navigation (default: true) */
|
|
@@ -167,6 +173,8 @@ export interface UseScrollReturn {
|
|
|
167
173
|
export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
168
174
|
const {
|
|
169
175
|
axis = "vertical",
|
|
176
|
+
initialViewportSize,
|
|
177
|
+
initialContentSize,
|
|
170
178
|
initialOffset = 0,
|
|
171
179
|
enableKeyboard = true,
|
|
172
180
|
enableMouseWheel = true,
|
|
@@ -177,12 +185,14 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
177
185
|
} = options
|
|
178
186
|
|
|
179
187
|
const { width: termWidth, height: termHeight } = useTerminalSize()
|
|
188
|
+
const baseViewportSize = initialViewportSize ?? (axis === "vertical" ? termHeight : termWidth)
|
|
180
189
|
|
|
181
190
|
// Scroll state
|
|
182
191
|
const [offset, setOffsetRaw] = useState(initialOffset)
|
|
183
|
-
const [contentSize, setContentSize] = useState(0)
|
|
192
|
+
const [contentSize, setContentSize] = useState(initialContentSize ?? 0)
|
|
184
193
|
// Use terminal size as initial estimate, but scroll component will report actual size
|
|
185
|
-
const [viewportSize, setViewportSize] = useState(
|
|
194
|
+
const [viewportSize, setViewportSize] = useState(baseViewportSize)
|
|
195
|
+
const [viewportMeasured, setViewportMeasured] = useState(false)
|
|
186
196
|
|
|
187
197
|
// Refs for sticky scroll behavior
|
|
188
198
|
const wasAtEndRef = useRef(sticky)
|
|
@@ -267,6 +277,7 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
267
277
|
(width: number, height: number) => {
|
|
268
278
|
const newSize = axis === "vertical" ? height : width
|
|
269
279
|
setViewportSize(newSize)
|
|
280
|
+
setViewportMeasured(true)
|
|
270
281
|
},
|
|
271
282
|
[axis],
|
|
272
283
|
)
|
|
@@ -351,6 +362,7 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
351
362
|
offset,
|
|
352
363
|
maxOffset,
|
|
353
364
|
viewportSize,
|
|
365
|
+
viewportMeasured,
|
|
354
366
|
contentSize,
|
|
355
367
|
atStart,
|
|
356
368
|
atEnd,
|
package/src/test/render-tui.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { KeyMsg } from "@effect-tui/core"
|
|
2
|
-
import
|
|
2
|
+
import React, { type ReactElement, useState } from "react"
|
|
3
3
|
import { flushPassiveEffects, flushSync } from "../reconciler/host-config.js"
|
|
4
4
|
import { createRenderer, createRoot } from "../renderer.js"
|
|
5
5
|
import { getVisibleLines, MockStdin, MockStdout, stripAnsi } from "./mock-streams.js"
|
|
@@ -69,7 +69,15 @@ export function renderTUI(element: ReactElement, options?: RenderTUIOptions): Re
|
|
|
69
69
|
})
|
|
70
70
|
|
|
71
71
|
const root = createRoot(renderer)
|
|
72
|
-
|
|
72
|
+
let bump: (() => void) | null = null
|
|
73
|
+
const Harness = ({ children }: { children: ReactElement }) => {
|
|
74
|
+
const [, setTick] = useState(0)
|
|
75
|
+
bump = () => setTick((value) => value + 1)
|
|
76
|
+
return children
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const harnessed = React.createElement(Harness, null, element)
|
|
80
|
+
root.render(harnessed, true) // sync mode
|
|
73
81
|
|
|
74
82
|
// Flush effects
|
|
75
83
|
flushPassiveEffects()
|
|
@@ -79,12 +87,22 @@ export function renderTUI(element: ReactElement, options?: RenderTUIOptions): Re
|
|
|
79
87
|
|
|
80
88
|
const flush = () => {
|
|
81
89
|
// Flush React updates synchronously
|
|
82
|
-
flushSync(() => {
|
|
90
|
+
flushSync(() => {
|
|
91
|
+
bump?.()
|
|
92
|
+
})
|
|
83
93
|
flushPassiveEffects()
|
|
84
94
|
// Clear buffer before re-render to get clean frame
|
|
85
95
|
stdout.clear()
|
|
86
96
|
renderer.requestRender()
|
|
87
97
|
renderer.flush()
|
|
98
|
+
// Flush updates scheduled during layout (e.g., viewport/content size callbacks)
|
|
99
|
+
flushSync(() => {
|
|
100
|
+
bump?.()
|
|
101
|
+
})
|
|
102
|
+
flushPassiveEffects()
|
|
103
|
+
stdout.clear()
|
|
104
|
+
renderer.requestRender()
|
|
105
|
+
renderer.flush()
|
|
88
106
|
}
|
|
89
107
|
|
|
90
108
|
return {
|