@effect-tui/react 0.5.0 → 0.6.1

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.5.0",
3
+ "version": "0.6.1",
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.5.0",
86
+ "@effect-tui/core": "^0.6.1",
87
87
  "@effect/platform": "^0.94.0",
88
88
  "@effect/platform-bun": "^0.87.0",
89
89
  "@effect/rpc": "^0.73.0",
@@ -0,0 +1,138 @@
1
+ // ListView.tsx — Virtualized selection-aware scrolling list
2
+ import type { ReactNode } from "react"
3
+ import { useEffect, useRef } from "react"
4
+ import { useScroll } from "../hooks/use-scroll.js"
5
+
6
+ export interface ListViewProps<T> {
7
+ /** Items to render */
8
+ items: readonly T[]
9
+ /** Currently selected index */
10
+ selectedIndex: number
11
+ /** Render function for each item */
12
+ renderItem: (item: T, index: number, isSelected: boolean) => ReactNode
13
+ /** Extract unique key for each item */
14
+ keyExtractor: (item: T, index: number) => string | number
15
+ /** Height of each item in rows (default: 1) */
16
+ itemHeight?: number
17
+ /** Padding rows to keep around selected item (default: 0) */
18
+ scrollPadding?: number
19
+ /** Show scrollbar (default: true) */
20
+ showScrollbar?: boolean
21
+ /** Empty state content when items is empty */
22
+ emptyContent?: ReactNode
23
+ /** Number of extra items to render above/below viewport for smooth scrolling (default: 3) */
24
+ overscan?: number
25
+ }
26
+
27
+ /**
28
+ * A virtualized scrolling list view with selection and scroll-into-view behavior.
29
+ *
30
+ * Features:
31
+ * - **Virtualized**: Only renders visible items + overscan buffer for performance
32
+ * - Automatically scrolls to keep selected item visible when selection changes
33
+ * - Supports mouse wheel scrolling with macOS-style acceleration
34
+ * - Shows a scroll bar when content overflows
35
+ *
36
+ * Keyboard navigation should be handled by the parent component - update
37
+ * `selectedIndex` and ListView will scroll to keep it visible.
38
+ *
39
+ * @example
40
+ * ```tsx
41
+ * const [selectedIndex, setSelectedIndex] = useState(0)
42
+ *
43
+ * // Handle keyboard in parent
44
+ * useKeyboard((key) => {
45
+ * if (key.name === "down" || key.text === "j") {
46
+ * setSelectedIndex(i => Math.min(items.length - 1, i + 1))
47
+ * }
48
+ * if (key.name === "up" || key.text === "k") {
49
+ * setSelectedIndex(i => Math.max(0, i - 1))
50
+ * }
51
+ * })
52
+ *
53
+ * return (
54
+ * <ListView
55
+ * items={cards}
56
+ * selectedIndex={selectedIndex}
57
+ * renderItem={(card, i, isSelected) => (
58
+ * <CardListItem card={card} isSelected={isSelected} />
59
+ * )}
60
+ * keyExtractor={(card) => card.id}
61
+ * />
62
+ * )
63
+ * ```
64
+ */
65
+ export function ListView<T>({
66
+ items,
67
+ selectedIndex,
68
+ renderItem,
69
+ keyExtractor,
70
+ itemHeight = 1,
71
+ scrollPadding = 0,
72
+ showScrollbar = true,
73
+ emptyContent,
74
+ overscan = 3,
75
+ }: ListViewProps<T>) {
76
+ const { state, scrollProps, scrollToVisible } = useScroll({
77
+ enableKeyboard: false, // Parent handles keyboard for selection
78
+ enableMouseWheel: true, // Free scroll with wheel
79
+ })
80
+
81
+ // Track previous selection to detect changes
82
+ const prevSelectedRef = useRef(selectedIndex)
83
+
84
+ // Scroll to keep selection visible when it changes
85
+ useEffect(() => {
86
+ if (selectedIndex !== prevSelectedRef.current) {
87
+ prevSelectedRef.current = selectedIndex
88
+ scrollToVisible(selectedIndex, itemHeight, scrollPadding)
89
+ }
90
+ }, [selectedIndex, itemHeight, scrollPadding, scrollToVisible])
91
+
92
+ // Also scroll on initial render if selection is not 0
93
+ useEffect(() => {
94
+ if (selectedIndex > 0) {
95
+ scrollToVisible(selectedIndex, itemHeight, scrollPadding)
96
+ }
97
+ // Only run on mount
98
+ // eslint-disable-next-line react-hooks/exhaustive-deps
99
+ }, [])
100
+
101
+ if (items.length === 0 && emptyContent) {
102
+ return <>{emptyContent}</>
103
+ }
104
+
105
+ // 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)
109
+
110
+ // Calculate spacer heights for virtual scrolling
111
+ const topSpacerHeight = startIndex * itemHeight
112
+ const bottomSpacerHeight = Math.max(0, totalHeight - endIndex * itemHeight)
113
+
114
+ // Get visible slice of items
115
+ const visibleItems = items.slice(startIndex, endIndex)
116
+
117
+ return (
118
+ <scroll {...scrollProps} showScrollbar={showScrollbar}>
119
+ <vstack>
120
+ {/* Virtual top spacer */}
121
+ {topSpacerHeight > 0 && <spacer height={topSpacerHeight} />}
122
+
123
+ {/* Render only visible items */}
124
+ {visibleItems.map((item, i) => {
125
+ const actualIndex = startIndex + i
126
+ return (
127
+ <vstack key={keyExtractor(item, actualIndex)}>
128
+ {renderItem(item, actualIndex, actualIndex === selectedIndex)}
129
+ </vstack>
130
+ )
131
+ })}
132
+
133
+ {/* Virtual bottom spacer */}
134
+ {bottomSpacerHeight > 0 && <spacer height={bottomSpacerHeight} />}
135
+ </vstack>
136
+ </scroll>
137
+ )
138
+ }
@@ -1,4 +1,5 @@
1
1
  export { Divider, type DividerProps } from "./Divider.js"
2
+ export { ListView, type ListViewProps } from "./ListView.js"
2
3
  export { Markdown, type MarkdownProps, type MarkdownTheme } from "./Markdown.js"
3
4
  export { MultilineTextInput, type MultilineTextInputProps } from "./MultilineTextInput.js"
4
5
  export { Overlay, type OverlayItemProps, type OverlayProps } from "./Overlay.js"
package/src/index.ts CHANGED
@@ -6,6 +6,8 @@ export { CodeBlock } from "./codeblock.js"
6
6
  // Input components
7
7
  export {
8
8
  type ColumnProps,
9
+ ListView,
10
+ type ListViewProps,
9
11
  Markdown,
10
12
  type MarkdownProps,
11
13
  type MarkdownTheme,