@effect-tui/react 0.10.0 → 0.10.2

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.
Files changed (58) hide show
  1. package/dist/src/components/ListView.d.ts.map +1 -1
  2. package/dist/src/components/ListView.js +7 -2
  3. package/dist/src/components/ListView.js.map +1 -1
  4. package/dist/src/hooks/use-scroll.d.ts.map +1 -1
  5. package/dist/src/hooks/use-scroll.js +12 -4
  6. package/dist/src/hooks/use-scroll.js.map +1 -1
  7. package/dist/src/hooks/use-timer.d.ts +2 -2
  8. package/dist/src/hooks/use-timer.d.ts.map +1 -1
  9. package/dist/src/hooks/use-timer.js +2 -2
  10. package/dist/src/hooks/use-timer.js.map +1 -1
  11. package/dist/src/hosts/base.d.ts +8 -0
  12. package/dist/src/hosts/base.d.ts.map +1 -1
  13. package/dist/src/hosts/base.js +14 -0
  14. package/dist/src/hosts/base.js.map +1 -1
  15. package/dist/src/hosts/box.js.map +1 -1
  16. package/dist/src/hosts/canvas.js.map +1 -1
  17. package/dist/src/hosts/codeblock.js.map +1 -1
  18. package/dist/src/hosts/flex-container.js.map +1 -1
  19. package/dist/src/hosts/hstack.d.ts +3 -5
  20. package/dist/src/hosts/hstack.d.ts.map +1 -1
  21. package/dist/src/hosts/hstack.js.map +1 -1
  22. package/dist/src/hosts/overlay-item.js.map +1 -1
  23. package/dist/src/hosts/scroll.js.map +1 -1
  24. package/dist/src/hosts/spacer.js.map +1 -1
  25. package/dist/src/hosts/text.js.map +1 -1
  26. package/dist/src/hosts/vstack.d.ts +3 -5
  27. package/dist/src/hosts/vstack.d.ts.map +1 -1
  28. package/dist/src/hosts/vstack.js.map +1 -1
  29. package/dist/src/hosts/zstack.js.map +1 -1
  30. package/dist/src/index.d.ts +2 -2
  31. package/dist/src/index.d.ts.map +1 -1
  32. package/dist/src/index.js +1 -1
  33. package/dist/src/index.js.map +1 -1
  34. package/dist/src/reconciler/types.d.ts +24 -1
  35. package/dist/src/reconciler/types.d.ts.map +1 -1
  36. package/dist/src/test-grow.d.ts +3 -0
  37. package/dist/src/test-grow.d.ts.map +1 -0
  38. package/dist/src/test-grow.js +8 -0
  39. package/dist/src/test-grow.js.map +1 -0
  40. package/dist/tsconfig.tsbuildinfo +1 -1
  41. package/package.json +2 -2
  42. package/src/components/ListView.tsx +8 -2
  43. package/src/hooks/use-scroll.ts +12 -4
  44. package/src/hooks/use-timer.ts +9 -6
  45. package/src/hosts/base.ts +17 -0
  46. package/src/hosts/box.ts +1 -1
  47. package/src/hosts/canvas.ts +1 -1
  48. package/src/hosts/codeblock.ts +1 -1
  49. package/src/hosts/flex-container.ts +1 -1
  50. package/src/hosts/hstack.ts +2 -5
  51. package/src/hosts/overlay-item.ts +1 -1
  52. package/src/hosts/scroll.ts +1 -1
  53. package/src/hosts/spacer.ts +1 -1
  54. package/src/hosts/text.ts +3 -3
  55. package/src/hosts/vstack.ts +2 -5
  56. package/src/hosts/zstack.ts +1 -1
  57. package/src/index.ts +5 -1
  58. package/src/reconciler/types.ts +21 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-tui/react",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
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.10.0",
86
+ "@effect-tui/core": "^0.10.2",
87
87
  "@effect/platform": "^0.94.0",
88
88
  "@effect/platform-bun": "^0.87.0",
89
89
  "@effect/rpc": "^0.73.0",
@@ -123,12 +123,18 @@ export function ListView<T>({
123
123
 
124
124
  // Track previous selection to detect changes
125
125
  const prevSelectedRef = useRef(selectedIndex)
126
+ // Track previous scrollToVisible reference to detect viewport changes
127
+ const prevScrollToVisibleRef = useRef(scrollToVisible)
126
128
 
127
- // Scroll to keep selection visible when it changes
129
+ // Scroll to keep selection visible when it changes OR when viewport size changes
128
130
  // useLayoutEffect runs before paint - prevents visible "jump" when navigating
129
131
  useLayoutEffect(() => {
130
- if (selectedIndex !== prevSelectedRef.current) {
132
+ const selectionChanged = selectedIndex !== prevSelectedRef.current
133
+ const viewportChanged = scrollToVisible !== prevScrollToVisibleRef.current
134
+
135
+ if (selectionChanged || viewportChanged) {
131
136
  prevSelectedRef.current = selectedIndex
137
+ prevScrollToVisibleRef.current = scrollToVisible
132
138
  // Pass totalHeight to avoid stale contentSize issues when jumping to end
133
139
  scrollToVisible(selectedIndex, itemHeight, scrollPadding, totalHeight)
134
140
  }
@@ -353,7 +353,9 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
353
353
  useKeyboard(handleKey)
354
354
 
355
355
  // Scroll to make a position visible (for keeping selection in view)
356
- // Uses refs to avoid stale closures - only triggers when selection changes
356
+ // Uses refs to avoid stale closures, but re-creates when viewport size changes
357
+ // so selection effects can re-run after measurement updates.
358
+ // Bypasses clampOffset because it uses totalSize for accurate clamping
357
359
  const scrollToVisible = useCallback(
358
360
  (position: number, itemSize = 1, padding = 0, totalSize?: number) => {
359
361
  const currentOffset = offsetRef.current
@@ -367,15 +369,21 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
367
369
 
368
370
  // If item is above viewport, scroll up to show it
369
371
  if (isAbove) {
370
- setOffset(Math.max(0, itemStart - padding))
372
+ const newOffset = Math.max(0, itemStart - padding)
373
+ offsetRef.current = newOffset
374
+ wasAtEndRef.current = false
375
+ setOffsetRaw(newOffset)
371
376
  }
372
377
  // If item is below viewport, scroll down to show it
373
378
  else if (isBelow) {
374
379
  const currentMaxOffset = Math.max(0, effectiveContentSize - currentViewportSize)
375
- setOffset(Math.min(currentMaxOffset, itemEnd - currentViewportSize + padding))
380
+ const newOffset = Math.min(currentMaxOffset, itemEnd - currentViewportSize + padding)
381
+ offsetRef.current = newOffset
382
+ wasAtEndRef.current = newOffset >= currentMaxOffset - 1
383
+ setOffsetRaw(newOffset)
376
384
  }
377
385
  },
378
- [contentSize, setOffset],
386
+ [contentSize, viewportSize],
379
387
  )
380
388
 
381
389
  const state: ScrollState = {
@@ -33,8 +33,8 @@ export interface UseTimerReturn {
33
33
  start: () => void
34
34
  /** Pause the timer */
35
35
  pause: () => void
36
- /** Reset timer to initialTime and stop */
37
- reset: () => void
36
+ /** Reset timer to initialTime (or provided time) and stop */
37
+ reset: (newTime?: number) => void
38
38
  /** Current timer status */
39
39
  status: TimerStatus
40
40
  }
@@ -101,10 +101,13 @@ export function useTimer(config: UseTimerConfig): UseTimerReturn {
101
101
  setStatus((prev) => (prev === "RUNNING" ? "PAUSED" : prev))
102
102
  }, [])
103
103
 
104
- const reset = useCallback(() => {
105
- setTime(initialTime)
106
- setStatus("STOPPED")
107
- }, [initialTime])
104
+ const reset = useCallback(
105
+ (newTime?: number) => {
106
+ setTime(newTime ?? initialTime)
107
+ setStatus("STOPPED")
108
+ },
109
+ [initialTime],
110
+ )
108
111
 
109
112
  // Timer effect
110
113
  useEffect(() => {
package/src/hosts/base.ts CHANGED
@@ -88,6 +88,13 @@ export abstract class BaseHost implements HostInstance {
88
88
  /** @internal Marks this node as static content (for Static component) */
89
89
  __static?: boolean
90
90
 
91
+ // ─────────────────────────────────────────────────────────────
92
+ // onLayout callback - fires when layout size changes
93
+ // ─────────────────────────────────────────────────────────────
94
+ onLayout?: (size: { width: number; height: number; x: number; y: number }) => void
95
+ private _lastLayoutW = -1
96
+ private _lastLayoutH = -1
97
+
91
98
  protected ctx: HostContext
92
99
 
93
100
  // ─────────────────────────────────────────────────────────────
@@ -200,6 +207,13 @@ export abstract class BaseHost implements HostInstance {
200
207
  if (this.frameMaxHeight !== undefined) h = Math.min(this.frameMaxHeight, h)
201
208
 
202
209
  this.rect = { x: rect.x, y: rect.y, w, h }
210
+
211
+ // Fire onLayout callback if size changed (deduplicated)
212
+ if (this.onLayout && (w !== this._lastLayoutW || h !== this._lastLayoutH)) {
213
+ this._lastLayoutW = w
214
+ this._lastLayoutH = h
215
+ this.onLayout({ width: w, height: h, x: rect.x, y: rect.y })
216
+ }
203
217
  }
204
218
 
205
219
  /**
@@ -303,6 +317,9 @@ export abstract class BaseHost implements HostInstance {
303
317
  this.frameMaxWidth = typeof props.maxWidth === "number" ? props.maxWidth : undefined
304
318
  this.frameMinHeight = typeof props.minHeight === "number" ? props.minHeight : undefined
305
319
  this.frameMaxHeight = typeof props.maxHeight === "number" ? props.maxHeight : undefined
320
+
321
+ // onLayout callback
322
+ this.onLayout = typeof props.onLayout === "function" ? (props.onLayout as typeof this.onLayout) : undefined
306
323
  }
307
324
 
308
325
  destroy(): void {
package/src/hosts/box.ts CHANGED
@@ -40,7 +40,7 @@ export class BoxHost extends SingleChildHost {
40
40
 
41
41
  constructor(props: BoxProps, ctx: HostContext) {
42
42
  super("box", props, ctx)
43
- this.updateProps(props)
43
+ this.updateProps(props as Record<string, unknown>)
44
44
  }
45
45
 
46
46
  private get borderThickness(): number {
@@ -75,7 +75,7 @@ export class CanvasHost extends BaseHost {
75
75
 
76
76
  constructor(props: CanvasProps, ctx: HostContext) {
77
77
  super("canvas", props, ctx)
78
- this.updateProps(props)
78
+ this.updateProps(props as Record<string, unknown>)
79
79
  }
80
80
 
81
81
  measure(maxW: number, maxH: number): Size {
@@ -30,7 +30,7 @@ export class CodeBlockHost extends BaseHost {
30
30
 
31
31
  constructor(props: CodeBlockProps, ctx: HostContext) {
32
32
  super("codeblock", props, ctx)
33
- this.updateProps(props)
33
+ this.updateProps(props as Record<string, unknown>)
34
34
  }
35
35
 
36
36
  private computeGutterWidth(): number {
@@ -46,7 +46,7 @@ export class FlexContainerHost<A extends FlexAxis> extends BaseHost {
46
46
  ) {
47
47
  super(elementType, props, ctx)
48
48
  this.alignment = defaultAlignment
49
- this.updateProps(props)
49
+ this.updateProps(props as Record<string, unknown>)
50
50
  }
51
51
 
52
52
  /** Get children excluding __static nodes (which are rendered separately) */
@@ -1,10 +1,7 @@
1
- import type { CommonProps, HostContext } from "../reconciler/types.js"
1
+ import type { HostContext } from "../reconciler/types.js"
2
2
  import { FlexContainerHost, type FlexContainerProps } from "./flex-container.js"
3
3
 
4
- export interface HStackProps extends CommonProps {
5
- spacing?: number
6
- alignment?: "top" | "center" | "bottom"
7
- }
4
+ export interface HStackProps extends FlexContainerProps<"horizontal"> {}
8
5
 
9
6
  /**
10
7
  * HStackHost lays out children horizontally with optional spacing and cross-axis alignment.
@@ -23,7 +23,7 @@ export class OverlayItemHost extends SingleChildHost {
23
23
 
24
24
  constructor(props: OverlayItemProps, ctx: HostContext) {
25
25
  super("overlayItem", props, ctx)
26
- this.updateProps(props)
26
+ this.updateProps(props as Record<string, unknown>)
27
27
  }
28
28
 
29
29
  override measure(maxW: number, maxH: number): Size {
@@ -64,7 +64,7 @@ export class ScrollHost extends SingleChildHost {
64
64
 
65
65
  constructor(props: ScrollProps, ctx: HostContext) {
66
66
  super("scroll", props, ctx)
67
- this.updateProps(props)
67
+ this.updateProps(props as Record<string, unknown>)
68
68
  }
69
69
 
70
70
  measure(maxW: number, maxH: number): Size {
@@ -18,7 +18,7 @@ export class SpacerHost extends BaseHost {
18
18
 
19
19
  constructor(props: SpacerProps, ctx: HostContext) {
20
20
  super("spacer", props, ctx)
21
- this.updateProps(props)
21
+ this.updateProps(props as Record<string, unknown>)
22
22
  }
23
23
 
24
24
  measure(_maxW: number, _maxH: number): Size {
package/src/hosts/text.ts CHANGED
@@ -38,7 +38,7 @@ export class TextHost extends BaseHost {
38
38
 
39
39
  constructor(props: TextProps, ctx: HostContext) {
40
40
  super("text", props, ctx)
41
- this.updateProps(props)
41
+ this.updateProps(props as Record<string, unknown>)
42
42
  }
43
43
 
44
44
  /** Check if we have SpanHost children (requires styled rendering) */
@@ -447,7 +447,7 @@ export class SpanHost extends BaseHost {
447
447
 
448
448
  constructor(props: SpanProps, ctx: HostContext) {
449
449
  super("span", props, ctx)
450
- this.updateProps(props)
450
+ this.updateProps(props as Record<string, unknown>)
451
451
  }
452
452
 
453
453
  /** Get text content from RawTextHost children */
@@ -523,7 +523,7 @@ export class StyledTextHost extends BaseHost {
523
523
 
524
524
  constructor(props: StyledTextProps, ctx: HostContext) {
525
525
  super("styledtext", props, ctx)
526
- this.updateProps(props)
526
+ this.updateProps(props as Record<string, unknown>)
527
527
  }
528
528
 
529
529
  measure(maxW: number, maxH: number): Size {
@@ -1,10 +1,7 @@
1
- import type { CommonProps, HostContext } from "../reconciler/types.js"
1
+ import type { HostContext } from "../reconciler/types.js"
2
2
  import { FlexContainerHost, type FlexContainerProps } from "./flex-container.js"
3
3
 
4
- export interface VStackProps extends CommonProps {
5
- spacing?: number
6
- alignment?: "leading" | "center" | "trailing"
7
- }
4
+ export interface VStackProps extends FlexContainerProps<"vertical"> {}
8
5
 
9
6
  /**
10
7
  * VStackHost lays out children vertically with optional spacing and cross-axis alignment.
@@ -15,7 +15,7 @@ export class ZStackHost extends BaseHost {
15
15
 
16
16
  constructor(props: ZStackProps, ctx: HostContext) {
17
17
  super("zstack", props, ctx)
18
- this.updateProps(props)
18
+ this.updateProps(props as Record<string, unknown>)
19
19
  }
20
20
 
21
21
  measure(maxW: number, maxH: number): Size {
package/src/index.ts CHANGED
@@ -48,13 +48,17 @@ export {
48
48
  } from "./highlight.js"
49
49
  export type {
50
50
  ScrollState,
51
+ TimerStatus,
52
+ TimerType,
51
53
  UseKeyboardOptions,
52
54
  UseMouseOptions,
53
55
  UseScrollOptions,
54
56
  UseScrollReturn,
57
+ UseTimerConfig,
58
+ UseTimerReturn,
55
59
  } from "./hooks/index.js"
56
60
  // Hooks
57
- export { useKeyboard, useMouse, usePaste, useQuit, useScroll } from "./hooks/index.js"
61
+ export { useKeyboard, useMouse, usePaste, useQuit, useScroll, useTimer } from "./hooks/index.js"
58
62
  export { useFrameStats } from "./hooks/useFrameStats.js"
59
63
  export type { BorderKind, BoxProps } from "./hosts/box.js"
60
64
  export type { CanvasProps, DrawContext } from "./hosts/canvas.js"
@@ -132,6 +132,26 @@ export interface CommonProps {
132
132
  /** Maximum height - natural size won't exceed this */
133
133
  maxHeight?: number
134
134
 
135
- /** Index signature for Record<string, unknown> compatibility */
135
+ /**
136
+ * Called after layout with the element's final dimensions.
137
+ * Fires only when size changes (deduplicated).
138
+ *
139
+ * @example
140
+ * ```tsx
141
+ * const [size, setSize] = useState({ width: 0, height: 0 })
142
+ * <vstack greedy onLayout={setSize}>
143
+ * <text>Width: {size.width}</text>
144
+ * </vstack>
145
+ * ```
146
+ */
147
+ onLayout?: (size: { width: number; height: number; x: number; y: number }) => void
148
+
149
+ /** React key prop for list reconciliation */
150
+ key?: string | number
151
+
152
+ /**
153
+ * Index signature for Record<string, unknown> compatibility.
154
+ * TODO: Remove this and properly type all props to catch typos like "grow" instead of "greedy"
155
+ */
136
156
  [key: string]: unknown
137
157
  }