ink-virtual-list 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 archcorsair
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,213 @@
1
+ # ink-virtual-list
2
+
3
+ A virtualized list component for [Ink](https://github.com/vadimdemedes/ink) terminal applications. Only renders visible items for optimal performance with large datasets.
4
+
5
+ ## Features
6
+
7
+ - **Virtualized rendering** - Only renders items visible in the viewport
8
+ - **Automatic scrolling** - Keeps selected item in view as you navigate
9
+ - **Terminal-aware** - Responds to terminal resize events
10
+ - **Flexible height** - Fixed height or auto-fill available terminal space
11
+ - **Customizable indicators** - Override default overflow indicators ("▲ N more")
12
+ - **TypeScript first** - Full type safety with generics
13
+ - **Imperative API** - Programmatic scrolling via ref
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ # npm
19
+ npm install ink-virtual-list
20
+
21
+ # jsr
22
+ npx jsr add @archcorsair/ink-virtual-list
23
+
24
+ # bun
25
+ bun add ink-virtual-list
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ### Basic Example
31
+
32
+ ```tsx
33
+ import { VirtualList } from 'ink-virtual-list';
34
+ import { Text } from 'ink';
35
+ import { useState } from 'react';
36
+
37
+ function App() {
38
+ const [selectedIndex, setSelectedIndex] = useState(0);
39
+ const items = Array.from({ length: 1000 }, (_, i) => `Item ${i + 1}`);
40
+
41
+ return (
42
+ <VirtualList
43
+ items={items}
44
+ selectedIndex={selectedIndex}
45
+ height={10}
46
+ renderItem={({ item, isSelected }) => (
47
+ <Text color={isSelected ? 'cyan' : 'white'}>
48
+ {isSelected ? '> ' : ' '}
49
+ {item}
50
+ </Text>
51
+ )}
52
+ />
53
+ );
54
+ }
55
+ ```
56
+
57
+ ### Auto-fill Terminal Height
58
+
59
+ ```tsx
60
+ <VirtualList
61
+ items={items}
62
+ height="auto"
63
+ reservedLines={5} // Reserve space for header/footer
64
+ renderItem={({ item }) => <Text>{item}</Text>}
65
+ />
66
+ ```
67
+
68
+ ### Custom Overflow Indicators
69
+
70
+ ```tsx
71
+ <VirtualList
72
+ items={items}
73
+ renderOverflowTop={(count) => <Text dimColor>↑ {count} hidden</Text>}
74
+ renderOverflowBottom={(count) => <Text dimColor>↓ {count} hidden</Text>}
75
+ renderItem={({ item }) => <Text>{item}</Text>}
76
+ />
77
+ ```
78
+
79
+ ### Imperative Scrolling
80
+
81
+ ```tsx
82
+ import { useRef } from 'react';
83
+ import type { VirtualListRef } from 'ink-virtual-list';
84
+
85
+ function App() {
86
+ const listRef = useRef<VirtualListRef>(null);
87
+
88
+ const scrollToTop = () => {
89
+ listRef.current?.scrollToIndex(0, 'top');
90
+ };
91
+
92
+ return (
93
+ <VirtualList
94
+ ref={listRef}
95
+ items={items}
96
+ renderItem={({ item }) => <Text>{item}</Text>}
97
+ />
98
+ );
99
+ }
100
+ ```
101
+
102
+ ## API
103
+
104
+ ### Props
105
+
106
+ #### Required
107
+
108
+ - **`items: T[]`** - Array of items to render
109
+ - **`renderItem: (props: RenderItemProps<T>) => ReactNode`** - Render function for each visible item
110
+ - Receives: `{ item: T, index: number, isSelected: boolean }`
111
+
112
+ #### Optional
113
+
114
+ - **`selectedIndex?: number`** - Index of currently selected item (default: `0`)
115
+ - **`keyExtractor?: (item: T, index: number) => string`** - Custom key extractor for list items
116
+ - **`height?: number | "auto"`** - Fixed height in lines or `"auto"` to fill terminal (default: `10`)
117
+ - **`reservedLines?: number`** - Lines to reserve when using `height="auto"` (default: `0`)
118
+ - **`itemHeight?: number`** - Height of each item in lines (default: `1`)
119
+ - **`showOverflowIndicators?: boolean`** - Show "N more" indicators (default: `true`)
120
+ - **`renderOverflowTop?: (count: number) => ReactNode`** - Custom top overflow indicator
121
+ - **`renderOverflowBottom?: (count: number) => ReactNode`** - Custom bottom overflow indicator
122
+ - **`renderScrollBar?: (viewport: ViewportState) => ReactNode`** - Custom scrollbar renderer
123
+ - **`onViewportChange?: (viewport: ViewportState) => void`** - Callback when viewport changes
124
+
125
+ ### Ref Methods
126
+
127
+ ```typescript
128
+ interface VirtualListRef {
129
+ scrollToIndex: (index: number, alignment?: 'auto' | 'top' | 'center' | 'bottom') => void;
130
+ getViewport: () => ViewportState;
131
+ remeasure: () => void;
132
+ }
133
+ ```
134
+
135
+ - **`scrollToIndex(index, alignment?)`** - Scroll to bring an index into view
136
+ - `'auto'` (default) - Only scroll if needed
137
+ - `'top'` - Align item to top of viewport
138
+ - `'center'` - Center item in viewport
139
+ - `'bottom'` - Align item to bottom of viewport
140
+ - **`getViewport()`** - Get current viewport state (`{ offset, visibleCount, totalCount }`)
141
+ - **`remeasure()`** - Force recalculation of viewport dimensions
142
+
143
+ ### Types
144
+
145
+ ```typescript
146
+ interface RenderItemProps<T> {
147
+ item: T;
148
+ index: number;
149
+ isSelected: boolean;
150
+ }
151
+
152
+ interface ViewportState {
153
+ offset: number; // Items scrolled past
154
+ visibleCount: number; // Items currently visible
155
+ totalCount: number; // Total items
156
+ }
157
+ ```
158
+
159
+ ## Advanced Example
160
+
161
+ ```tsx
162
+ import { VirtualList } from 'ink-virtual-list';
163
+ import { Box, Text } from 'ink';
164
+ import { useRef, useState } from 'react';
165
+ import type { VirtualListRef } from 'ink-virtual-list';
166
+
167
+ interface Todo {
168
+ id: string;
169
+ title: string;
170
+ completed: boolean;
171
+ }
172
+
173
+ function TodoApp() {
174
+ const [todos] = useState<Todo[]>([
175
+ { id: '1', title: 'Learn Ink', completed: true },
176
+ { id: '2', title: 'Build CLI', completed: false },
177
+ // ... 1000s more
178
+ ]);
179
+ const [selectedIndex, setSelectedIndex] = useState(0);
180
+ const listRef = useRef<VirtualListRef>(null);
181
+
182
+ return (
183
+ <Box flexDirection="column">
184
+ <Text bold>My Todos ({todos.length})</Text>
185
+
186
+ <VirtualList
187
+ ref={listRef}
188
+ items={todos}
189
+ selectedIndex={selectedIndex}
190
+ height="auto"
191
+ reservedLines={3}
192
+ keyExtractor={(todo) => todo.id}
193
+ renderItem={({ item, isSelected }) => (
194
+ <Box>
195
+ <Text color={isSelected ? 'cyan' : 'white'}>
196
+ {isSelected ? '❯ ' : ' '}
197
+ {item.completed ? '✓' : '○'} {item.title}
198
+ </Text>
199
+ </Box>
200
+ )}
201
+ />
202
+
203
+ <Text dimColor>
204
+ {selectedIndex + 1} / {todos.length}
205
+ </Text>
206
+ </Box>
207
+ );
208
+ }
209
+ ```
210
+
211
+ ## License
212
+
213
+ MIT
@@ -0,0 +1,82 @@
1
+ import { ReactNode } from "react";
2
+ /**
3
+ * Props passed to the renderItem function for each visible item.
4
+ */
5
+ interface RenderItemProps<T> {
6
+ /** The item data from the items array */
7
+ item: T;
8
+ /** The index of this item in the full items array */
9
+ index: number;
10
+ /** Whether this item is currently selected */
11
+ isSelected: boolean;
12
+ }
13
+ /**
14
+ * Represents the current viewport state of the virtual list.
15
+ */
16
+ interface ViewportState {
17
+ /** Number of items scrolled past (hidden above viewport) */
18
+ offset: number;
19
+ /** Number of items currently visible in the viewport */
20
+ visibleCount: number;
21
+ /** Total number of items in the list */
22
+ totalCount: number;
23
+ }
24
+ /**
25
+ * Props for the VirtualList component.
26
+ */
27
+ interface VirtualListProps<T> {
28
+ /** Array of items to render */
29
+ items: T[];
30
+ /** Render function for each visible item */
31
+ renderItem: (props: RenderItemProps<T>) => ReactNode;
32
+ /** Index of the currently selected item (default: 0) */
33
+ selectedIndex?: number;
34
+ /** Function to extract a unique key for each item */
35
+ keyExtractor?: (item: T, index: number) => string;
36
+ /** Fixed height in lines, or "auto" to fill available terminal space (default: 10) */
37
+ height?: number | "auto";
38
+ /** Lines to reserve when using height="auto" (e.g., for headers/footers) */
39
+ reservedLines?: number;
40
+ /** Height of each item in lines (default: 1) */
41
+ itemHeight?: number;
42
+ /** Whether to show "N more" indicators when items overflow (default: true) */
43
+ showOverflowIndicators?: boolean;
44
+ /** Custom renderer for the top overflow indicator */
45
+ renderOverflowTop?: (count: number) => ReactNode;
46
+ /** Custom renderer for the bottom overflow indicator */
47
+ renderOverflowBottom?: (count: number) => ReactNode;
48
+ /** Custom renderer for a scrollbar (receives viewport state) */
49
+ renderScrollBar?: (viewport: ViewportState) => ReactNode;
50
+ /** Callback fired when the viewport changes */
51
+ onViewportChange?: (viewport: ViewportState) => void;
52
+ }
53
+ /**
54
+ * Imperative handle methods exposed via ref.
55
+ */
56
+ interface VirtualListRef {
57
+ /** Scroll to bring a specific index into view */
58
+ scrollToIndex: (index: number, alignment?: "auto" | "top" | "center" | "bottom") => void;
59
+ /** Get the current viewport state */
60
+ getViewport: () => ViewportState;
61
+ /** Force recalculation of viewport dimensions */
62
+ remeasure: () => void;
63
+ }
64
+ /**
65
+ * Represents the terminal dimensions.
66
+ */
67
+ interface TerminalSize {
68
+ /** Number of rows (lines) in the terminal */
69
+ rows: number;
70
+ /** Number of columns (characters) in the terminal */
71
+ columns: number;
72
+ }
73
+ /**
74
+ * Hook that returns the current terminal size and updates on resize.
75
+ * Uses Ink's stdout to detect terminal dimensions.
76
+ */
77
+ declare function useTerminalSize(): TerminalSize;
78
+ declare function VirtualListInner<T>(props: VirtualListProps<T>, ref: React.ForwardedRef<VirtualListRef>): React.JSX.Element;
79
+ declare const VirtualList: <T>(props: VirtualListProps<T> & {
80
+ ref?: React.ForwardedRef<VirtualListRef>;
81
+ }) => ReturnType<typeof VirtualListInner>;
82
+ export { useTerminalSize, VirtualListRef, VirtualListProps, VirtualList, ViewportState, TerminalSize, RenderItemProps };
package/dist/index.js ADDED
@@ -0,0 +1,174 @@
1
+ // src/useTerminalSize.ts
2
+ import { useStdout } from "ink";
3
+ import { useEffect, useState } from "react";
4
+ var DEFAULT_ROWS = 24;
5
+ var DEFAULT_COLUMNS = 80;
6
+ function useTerminalSize() {
7
+ const { stdout } = useStdout();
8
+ const [size, setSize] = useState({
9
+ rows: stdout.rows ?? DEFAULT_ROWS,
10
+ columns: stdout.columns ?? DEFAULT_COLUMNS
11
+ });
12
+ useEffect(() => {
13
+ const handleResize = () => {
14
+ setSize({
15
+ rows: stdout.rows ?? DEFAULT_ROWS,
16
+ columns: stdout.columns ?? DEFAULT_COLUMNS
17
+ });
18
+ };
19
+ stdout.on("resize", handleResize);
20
+ return () => {
21
+ stdout.off("resize", handleResize);
22
+ };
23
+ }, [stdout]);
24
+ return size;
25
+ }
26
+ // src/VirtualList.tsx
27
+ import { Box, Text } from "ink";
28
+ import { forwardRef, useEffect as useEffect2, useImperativeHandle, useMemo, useState as useState2 } from "react";
29
+ import { jsxDEV } from "react/jsx-dev-runtime";
30
+ var DEFAULT_HEIGHT = 10;
31
+ var DEFAULT_ITEM_HEIGHT = 1;
32
+ function calculateViewportOffset(selectedIndex, currentOffset, visibleCount) {
33
+ if (selectedIndex < currentOffset) {
34
+ return selectedIndex;
35
+ }
36
+ if (selectedIndex >= currentOffset + visibleCount) {
37
+ return selectedIndex - visibleCount + 1;
38
+ }
39
+ return currentOffset;
40
+ }
41
+ function VirtualListInner(props, ref) {
42
+ const {
43
+ items,
44
+ renderItem,
45
+ selectedIndex = 0,
46
+ keyExtractor,
47
+ height = DEFAULT_HEIGHT,
48
+ reservedLines = 0,
49
+ itemHeight = DEFAULT_ITEM_HEIGHT,
50
+ showOverflowIndicators = true,
51
+ renderOverflowTop,
52
+ renderOverflowBottom,
53
+ renderScrollBar,
54
+ onViewportChange
55
+ } = props;
56
+ const { rows: terminalRows } = useTerminalSize();
57
+ const resolvedHeight = useMemo(() => {
58
+ if (typeof height === "number") {
59
+ return height;
60
+ }
61
+ return Math.max(1, terminalRows - reservedLines);
62
+ }, [height, terminalRows, reservedLines]);
63
+ const resolvedItemHeight = itemHeight ?? DEFAULT_ITEM_HEIGHT;
64
+ const indicatorLines = showOverflowIndicators ? 2 : 0;
65
+ const availableHeight = Math.max(0, resolvedHeight - indicatorLines);
66
+ const visibleCount = Math.floor(availableHeight / resolvedItemHeight);
67
+ const clampedSelectedIndex = Math.max(0, Math.min(selectedIndex, items.length - 1));
68
+ const calculatedOffset = useMemo(() => {
69
+ if (items.length === 0)
70
+ return 0;
71
+ const maxOffset = Math.max(0, items.length - visibleCount);
72
+ let offset = 0;
73
+ if (clampedSelectedIndex >= visibleCount) {
74
+ offset = clampedSelectedIndex - visibleCount + 1;
75
+ }
76
+ return Math.min(Math.max(0, offset), maxOffset);
77
+ }, [items.length, visibleCount, clampedSelectedIndex]);
78
+ const [viewportOffset, setViewportOffset] = useState2(calculatedOffset);
79
+ useEffect2(() => {
80
+ const newOffset = calculateViewportOffset(clampedSelectedIndex, viewportOffset, visibleCount);
81
+ if (newOffset !== viewportOffset) {
82
+ const maxOffset = Math.max(0, items.length - visibleCount);
83
+ setViewportOffset(Math.min(newOffset, maxOffset));
84
+ }
85
+ }, [clampedSelectedIndex, viewportOffset, visibleCount, items.length]);
86
+ const viewport = useMemo(() => ({
87
+ offset: viewportOffset,
88
+ visibleCount,
89
+ totalCount: items.length
90
+ }), [viewportOffset, visibleCount, items.length]);
91
+ useEffect2(() => {
92
+ onViewportChange?.(viewport);
93
+ }, [viewport, onViewportChange]);
94
+ useImperativeHandle(ref, () => ({
95
+ scrollToIndex: (index, alignment = "auto") => {
96
+ const clampedIndex = Math.max(0, Math.min(index, items.length - 1));
97
+ let newOffset;
98
+ switch (alignment) {
99
+ case "top":
100
+ newOffset = clampedIndex;
101
+ break;
102
+ case "center":
103
+ newOffset = clampedIndex - Math.floor(visibleCount / 2);
104
+ break;
105
+ case "bottom":
106
+ newOffset = clampedIndex - visibleCount + 1;
107
+ break;
108
+ default:
109
+ newOffset = calculateViewportOffset(clampedIndex, viewportOffset, visibleCount);
110
+ }
111
+ const maxOffset = Math.max(0, items.length - visibleCount);
112
+ setViewportOffset(Math.min(Math.max(0, newOffset), maxOffset));
113
+ },
114
+ getViewport: () => viewport,
115
+ remeasure: () => {
116
+ setViewportOffset((prev) => {
117
+ const maxOffset = Math.max(0, items.length - visibleCount);
118
+ return Math.min(prev, maxOffset);
119
+ });
120
+ }
121
+ }), [items.length, visibleCount, viewportOffset, viewport]);
122
+ const overflowTop = viewportOffset;
123
+ const overflowBottom = Math.max(0, items.length - viewportOffset - visibleCount);
124
+ const visibleItems = items.slice(viewportOffset, viewportOffset + visibleCount);
125
+ const defaultOverflowTop = (count) => /* @__PURE__ */ jsxDEV(Box, {
126
+ paddingLeft: 2,
127
+ children: /* @__PURE__ */ jsxDEV(Text, {
128
+ dimColor: true,
129
+ children: [
130
+ "▲ ",
131
+ count,
132
+ " more"
133
+ ]
134
+ }, undefined, true, undefined, this)
135
+ }, undefined, false, undefined, this);
136
+ const defaultOverflowBottom = (count) => /* @__PURE__ */ jsxDEV(Box, {
137
+ paddingLeft: 2,
138
+ children: /* @__PURE__ */ jsxDEV(Text, {
139
+ dimColor: true,
140
+ children: [
141
+ "▼ ",
142
+ count,
143
+ " more"
144
+ ]
145
+ }, undefined, true, undefined, this)
146
+ }, undefined, false, undefined, this);
147
+ const topIndicator = renderOverflowTop ?? defaultOverflowTop;
148
+ const bottomIndicator = renderOverflowBottom ?? defaultOverflowBottom;
149
+ return /* @__PURE__ */ jsxDEV(Box, {
150
+ flexDirection: "column",
151
+ children: [
152
+ showOverflowIndicators && overflowTop > 0 && topIndicator(overflowTop),
153
+ visibleItems.map((item, idx) => {
154
+ const actualIndex = viewportOffset + idx;
155
+ const key = keyExtractor ? keyExtractor(item, actualIndex) : String(actualIndex);
156
+ const itemProps = {
157
+ item,
158
+ index: actualIndex,
159
+ isSelected: actualIndex === clampedSelectedIndex
160
+ };
161
+ return /* @__PURE__ */ jsxDEV(Box, {
162
+ children: renderItem(itemProps)
163
+ }, key, false, undefined, this);
164
+ }),
165
+ showOverflowIndicators && overflowBottom > 0 && bottomIndicator(overflowBottom),
166
+ renderScrollBar?.(viewport)
167
+ ]
168
+ }, undefined, true, undefined, this);
169
+ }
170
+ var VirtualList = forwardRef(VirtualListInner);
171
+ export {
172
+ useTerminalSize,
173
+ VirtualList
174
+ };
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "ink-virtual-list",
3
+ "version": "0.1.0",
4
+ "description": "A virtualized list component for Ink terminal applications",
5
+ "type": "module",
6
+ "files": [
7
+ "dist",
8
+ "src"
9
+ ],
10
+ "main": "./dist/index.js",
11
+ "module": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js"
17
+ },
18
+ "./package.json": "./package.json"
19
+ },
20
+ "scripts": {
21
+ "build": "bunup",
22
+ "typecheck": "tsc --noEmit",
23
+ "lint": "biome check .",
24
+ "lint:fix": "biome check --write .",
25
+ "prepublishOnly": "bun run build",
26
+ "publish:npm": "npm publish",
27
+ "publish:jsr": "bun run build && npx jsr publish",
28
+ "version:patch": "bun pm version patch",
29
+ "version:minor": "bun pm version minor",
30
+ "version:major": "bun pm version major"
31
+ },
32
+ "keywords": [
33
+ "ink",
34
+ "react",
35
+ "cli",
36
+ "terminal",
37
+ "tui",
38
+ "virtual-list",
39
+ "virtualized",
40
+ "scrolling"
41
+ ],
42
+ "author": "",
43
+ "license": "MIT",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "https://github.com/archcorsair/ink-virtual-list"
47
+ },
48
+ "peerDependencies": {
49
+ "ink": "^6.6.0",
50
+ "react": "^19.2.3"
51
+ },
52
+ "devDependencies": {
53
+ "@biomejs/biome": "^2.3.10",
54
+ "@types/bun": "latest",
55
+ "@types/react": "^19.2.7",
56
+ "bunup": "^0.16.17",
57
+ "typescript": "^5.9.3"
58
+ }
59
+ }
@@ -0,0 +1,192 @@
1
+ import { Box, Text } from "ink";
2
+ import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react";
3
+ import type { RenderItemProps, ViewportState, VirtualListProps, VirtualListRef } from "./types";
4
+ import { useTerminalSize } from "./useTerminalSize";
5
+
6
+ const DEFAULT_HEIGHT = 10;
7
+ const DEFAULT_ITEM_HEIGHT = 1;
8
+
9
+ function calculateViewportOffset(selectedIndex: number, currentOffset: number, visibleCount: number): number {
10
+ // Selection above viewport - scroll up
11
+ if (selectedIndex < currentOffset) {
12
+ return selectedIndex;
13
+ }
14
+
15
+ // Selection below viewport - scroll down
16
+ if (selectedIndex >= currentOffset + visibleCount) {
17
+ return selectedIndex - visibleCount + 1;
18
+ }
19
+
20
+ // Selection visible - no change
21
+ return currentOffset;
22
+ }
23
+
24
+ function VirtualListInner<T>(props: VirtualListProps<T>, ref: React.ForwardedRef<VirtualListRef>): React.JSX.Element {
25
+ const {
26
+ items,
27
+ renderItem,
28
+ selectedIndex = 0,
29
+ keyExtractor,
30
+ height = DEFAULT_HEIGHT,
31
+ reservedLines = 0,
32
+ itemHeight = DEFAULT_ITEM_HEIGHT,
33
+ showOverflowIndicators = true,
34
+ renderOverflowTop,
35
+ renderOverflowBottom,
36
+ renderScrollBar,
37
+ onViewportChange,
38
+ } = props;
39
+
40
+ const { rows: terminalRows } = useTerminalSize();
41
+
42
+ // Calculate resolved height
43
+ const resolvedHeight = useMemo(() => {
44
+ if (typeof height === "number") {
45
+ return height;
46
+ }
47
+ // 'auto' - use terminal rows minus reserved
48
+ return Math.max(1, terminalRows - reservedLines);
49
+ }, [height, terminalRows, reservedLines]);
50
+
51
+ const resolvedItemHeight = itemHeight ?? DEFAULT_ITEM_HEIGHT;
52
+
53
+ // Reserve space for overflow indicators within the height budget
54
+ const indicatorLines = showOverflowIndicators ? 2 : 0;
55
+ const availableHeight = Math.max(0, resolvedHeight - indicatorLines);
56
+
57
+ // Calculate how many items fit in viewport
58
+ const visibleCount = Math.floor(availableHeight / resolvedItemHeight);
59
+
60
+ // Clamp selectedIndex to valid range
61
+ const clampedSelectedIndex = Math.max(0, Math.min(selectedIndex, items.length - 1));
62
+
63
+ // Calculate viewport offset - use useMemo to derive from selectedIndex
64
+ // This ensures the viewport is always in sync with selection
65
+ const calculatedOffset = useMemo(() => {
66
+ if (items.length === 0) return 0;
67
+
68
+ const maxOffset = Math.max(0, items.length - visibleCount);
69
+
70
+ // Calculate what offset would show the selected item
71
+ let offset = 0;
72
+
73
+ // Selection below viewport - scroll down
74
+ if (clampedSelectedIndex >= visibleCount) {
75
+ offset = clampedSelectedIndex - visibleCount + 1;
76
+ }
77
+
78
+ return Math.min(Math.max(0, offset), maxOffset);
79
+ }, [items.length, visibleCount, clampedSelectedIndex]);
80
+
81
+ const [viewportOffset, setViewportOffset] = useState(calculatedOffset);
82
+
83
+ // Sync viewportOffset when selection changes
84
+ useEffect(() => {
85
+ const newOffset = calculateViewportOffset(clampedSelectedIndex, viewportOffset, visibleCount);
86
+ if (newOffset !== viewportOffset) {
87
+ const maxOffset = Math.max(0, items.length - visibleCount);
88
+ setViewportOffset(Math.min(newOffset, maxOffset));
89
+ }
90
+ }, [clampedSelectedIndex, viewportOffset, visibleCount, items.length]);
91
+
92
+ // Build viewport state
93
+ const viewport: ViewportState = useMemo(
94
+ () => ({
95
+ offset: viewportOffset,
96
+ visibleCount,
97
+ totalCount: items.length,
98
+ }),
99
+ [viewportOffset, visibleCount, items.length],
100
+ );
101
+
102
+ // Notify on viewport change
103
+ useEffect(() => {
104
+ onViewportChange?.(viewport);
105
+ }, [viewport, onViewportChange]);
106
+
107
+ // Imperative handle
108
+ useImperativeHandle(
109
+ ref,
110
+ () => ({
111
+ scrollToIndex: (index: number, alignment: "auto" | "top" | "center" | "bottom" = "auto") => {
112
+ const clampedIndex = Math.max(0, Math.min(index, items.length - 1));
113
+ let newOffset: number;
114
+
115
+ switch (alignment) {
116
+ case "top":
117
+ newOffset = clampedIndex;
118
+ break;
119
+ case "center":
120
+ newOffset = clampedIndex - Math.floor(visibleCount / 2);
121
+ break;
122
+ case "bottom":
123
+ newOffset = clampedIndex - visibleCount + 1;
124
+ break;
125
+ default: // 'auto'
126
+ newOffset = calculateViewportOffset(clampedIndex, viewportOffset, visibleCount);
127
+ }
128
+
129
+ const maxOffset = Math.max(0, items.length - visibleCount);
130
+ setViewportOffset(Math.min(Math.max(0, newOffset), maxOffset));
131
+ },
132
+ getViewport: () => viewport,
133
+ remeasure: () => {
134
+ // Force recalculation by updating state
135
+ setViewportOffset((prev) => {
136
+ const maxOffset = Math.max(0, items.length - visibleCount);
137
+ return Math.min(prev, maxOffset);
138
+ });
139
+ },
140
+ }),
141
+ [items.length, visibleCount, viewportOffset, viewport],
142
+ );
143
+
144
+ // Calculate overflow counts
145
+ const overflowTop = viewportOffset;
146
+ const overflowBottom = Math.max(0, items.length - viewportOffset - visibleCount);
147
+
148
+ // Get visible items
149
+ const visibleItems = items.slice(viewportOffset, viewportOffset + visibleCount);
150
+
151
+ // Default overflow renderers (paddingLeft aligns with list content)
152
+ const defaultOverflowTop = (count: number) => (
153
+ <Box paddingLeft={2}>
154
+ <Text dimColor>▲ {count} more</Text>
155
+ </Box>
156
+ );
157
+ const defaultOverflowBottom = (count: number) => (
158
+ <Box paddingLeft={2}>
159
+ <Text dimColor>▼ {count} more</Text>
160
+ </Box>
161
+ );
162
+
163
+ const topIndicator = renderOverflowTop ?? defaultOverflowTop;
164
+ const bottomIndicator = renderOverflowBottom ?? defaultOverflowBottom;
165
+
166
+ return (
167
+ <Box flexDirection="column">
168
+ {showOverflowIndicators && overflowTop > 0 && topIndicator(overflowTop)}
169
+
170
+ {visibleItems.map((item, idx) => {
171
+ const actualIndex = viewportOffset + idx;
172
+ const key = keyExtractor ? keyExtractor(item, actualIndex) : String(actualIndex);
173
+
174
+ const itemProps: RenderItemProps<T> = {
175
+ item,
176
+ index: actualIndex,
177
+ isSelected: actualIndex === clampedSelectedIndex,
178
+ };
179
+
180
+ return <Box key={key}>{renderItem(itemProps)}</Box>;
181
+ })}
182
+
183
+ {showOverflowIndicators && overflowBottom > 0 && bottomIndicator(overflowBottom)}
184
+
185
+ {renderScrollBar?.(viewport)}
186
+ </Box>
187
+ );
188
+ }
189
+
190
+ export const VirtualList = forwardRef(VirtualListInner) as <T>(
191
+ props: VirtualListProps<T> & { ref?: React.ForwardedRef<VirtualListRef> },
192
+ ) => ReturnType<typeof VirtualListInner>;
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export type {
2
+ RenderItemProps,
3
+ ViewportState,
4
+ VirtualListProps,
5
+ VirtualListRef,
6
+ } from "./types";
7
+ export type { TerminalSize } from "./useTerminalSize";
8
+ export { useTerminalSize } from "./useTerminalSize";
9
+ export { VirtualList } from "./VirtualList";
package/src/types.ts ADDED
@@ -0,0 +1,72 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /**
4
+ * Props passed to the renderItem function for each visible item.
5
+ */
6
+ export interface RenderItemProps<T> {
7
+ /** The item data from the items array */
8
+ item: T;
9
+ /** The index of this item in the full items array */
10
+ index: number;
11
+ /** Whether this item is currently selected */
12
+ isSelected: boolean;
13
+ }
14
+
15
+ /**
16
+ * Represents the current viewport state of the virtual list.
17
+ */
18
+ export interface ViewportState {
19
+ /** Number of items scrolled past (hidden above viewport) */
20
+ offset: number;
21
+ /** Number of items currently visible in the viewport */
22
+ visibleCount: number;
23
+ /** Total number of items in the list */
24
+ totalCount: number;
25
+ }
26
+
27
+ /**
28
+ * Props for the VirtualList component.
29
+ */
30
+ export interface VirtualListProps<T> {
31
+ /** Array of items to render */
32
+ items: T[];
33
+ /** Render function for each visible item */
34
+ renderItem: (props: RenderItemProps<T>) => ReactNode;
35
+
36
+ /** Index of the currently selected item (default: 0) */
37
+ selectedIndex?: number;
38
+ /** Function to extract a unique key for each item */
39
+ keyExtractor?: (item: T, index: number) => string;
40
+
41
+ /** Fixed height in lines, or "auto" to fill available terminal space (default: 10) */
42
+ height?: number | "auto";
43
+ /** Lines to reserve when using height="auto" (e.g., for headers/footers) */
44
+ reservedLines?: number;
45
+ /** Height of each item in lines (default: 1) */
46
+ itemHeight?: number;
47
+
48
+ /** Whether to show "N more" indicators when items overflow (default: true) */
49
+ showOverflowIndicators?: boolean;
50
+ /** Custom renderer for the top overflow indicator */
51
+ renderOverflowTop?: (count: number) => ReactNode;
52
+ /** Custom renderer for the bottom overflow indicator */
53
+ renderOverflowBottom?: (count: number) => ReactNode;
54
+
55
+ /** Custom renderer for a scrollbar (receives viewport state) */
56
+ renderScrollBar?: (viewport: ViewportState) => ReactNode;
57
+
58
+ /** Callback fired when the viewport changes */
59
+ onViewportChange?: (viewport: ViewportState) => void;
60
+ }
61
+
62
+ /**
63
+ * Imperative handle methods exposed via ref.
64
+ */
65
+ export interface VirtualListRef {
66
+ /** Scroll to bring a specific index into view */
67
+ scrollToIndex: (index: number, alignment?: "auto" | "top" | "center" | "bottom") => void;
68
+ /** Get the current viewport state */
69
+ getViewport: () => ViewportState;
70
+ /** Force recalculation of viewport dimensions */
71
+ remeasure: () => void;
72
+ }
@@ -0,0 +1,43 @@
1
+ import { useStdout } from "ink";
2
+ import { useEffect, useState } from "react";
3
+
4
+ /**
5
+ * Represents the terminal dimensions.
6
+ */
7
+ export interface TerminalSize {
8
+ /** Number of rows (lines) in the terminal */
9
+ rows: number;
10
+ /** Number of columns (characters) in the terminal */
11
+ columns: number;
12
+ }
13
+
14
+ const DEFAULT_ROWS = 24;
15
+ const DEFAULT_COLUMNS = 80;
16
+
17
+ /**
18
+ * Hook that returns the current terminal size and updates on resize.
19
+ * Uses Ink's stdout to detect terminal dimensions.
20
+ */
21
+ export function useTerminalSize(): TerminalSize {
22
+ const { stdout } = useStdout();
23
+ const [size, setSize] = useState<TerminalSize>({
24
+ rows: stdout.rows ?? DEFAULT_ROWS,
25
+ columns: stdout.columns ?? DEFAULT_COLUMNS,
26
+ });
27
+
28
+ useEffect(() => {
29
+ const handleResize = () => {
30
+ setSize({
31
+ rows: stdout.rows ?? DEFAULT_ROWS,
32
+ columns: stdout.columns ?? DEFAULT_COLUMNS,
33
+ });
34
+ };
35
+
36
+ stdout.on("resize", handleResize);
37
+ return () => {
38
+ stdout.off("resize", handleResize);
39
+ };
40
+ }, [stdout]);
41
+
42
+ return size;
43
+ }