@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-tui/react",
3
- "version": "0.6.1",
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.1",
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 totalHeight = items.length * itemHeight
107
- const startIndex = Math.max(0, Math.floor(state.offset / itemHeight) - overscan)
108
- const endIndex = Math.min(items.length, Math.ceil((state.offset + state.viewportSize) / itemHeight) + overscan)
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 height={topSpacerHeight} />}
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 height={bottomSpacerHeight} />}
151
+ {bottomSpacerHeight > 0 && <spacer minHeight={bottomSpacerHeight} />}
135
152
  </vstack>
136
153
  </scroll>
137
154
  )
@@ -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(axis === "vertical" ? termHeight : termWidth)
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,
@@ -1,5 +1,5 @@
1
1
  import type { KeyMsg } from "@effect-tui/core"
2
- import type { ReactElement } from "react"
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
- root.render(element, true) // sync mode
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 {