ink-virtual-list 0.1.0 → 0.2.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 +21 -21
- package/README.md +213 -213
- package/dist/index.d.ts +4 -1
- package/dist/index.js +40 -13
- package/dist/index.js.map +11 -0
- package/package.json +4 -3
- package/src/VirtualList.tsx +0 -192
- package/src/index.ts +0 -9
- package/src/types.ts +0 -72
- package/src/useTerminalSize.ts +0 -43
package/LICENSE
CHANGED
|
@@ -1,21 +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.
|
|
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
CHANGED
|
@@ -1,213 +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
|
|
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
|
package/dist/index.d.ts
CHANGED
|
@@ -41,6 +41,8 @@ interface VirtualListProps<T> {
|
|
|
41
41
|
itemHeight?: number;
|
|
42
42
|
/** Whether to show "N more" indicators when items overflow (default: true) */
|
|
43
43
|
showOverflowIndicators?: boolean;
|
|
44
|
+
/** Minimum overflow count before showing indicators (default: 1) */
|
|
45
|
+
overflowIndicatorThreshold?: number;
|
|
44
46
|
/** Custom renderer for the top overflow indicator */
|
|
45
47
|
renderOverflowTop?: (count: number) => ReactNode;
|
|
46
48
|
/** Custom renderer for the bottom overflow indicator */
|
|
@@ -75,8 +77,9 @@ interface TerminalSize {
|
|
|
75
77
|
* Uses Ink's stdout to detect terminal dimensions.
|
|
76
78
|
*/
|
|
77
79
|
declare function useTerminalSize(): TerminalSize;
|
|
80
|
+
declare function validateItemHeight(itemHeight: number): void;
|
|
78
81
|
declare function VirtualListInner<T>(props: VirtualListProps<T>, ref: React.ForwardedRef<VirtualListRef>): React.JSX.Element;
|
|
79
82
|
declare const VirtualList: <T>(props: VirtualListProps<T> & {
|
|
80
83
|
ref?: React.ForwardedRef<VirtualListRef>;
|
|
81
84
|
}) => ReturnType<typeof VirtualListInner>;
|
|
82
|
-
export { useTerminalSize, VirtualListRef, VirtualListProps, VirtualList, ViewportState, TerminalSize, RenderItemProps };
|
|
85
|
+
export { validateItemHeight, useTerminalSize, VirtualListRef, VirtualListProps, VirtualList, ViewportState, TerminalSize, RenderItemProps };
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,9 @@ function useTerminalSize() {
|
|
|
10
10
|
columns: stdout.columns ?? DEFAULT_COLUMNS
|
|
11
11
|
});
|
|
12
12
|
useEffect(() => {
|
|
13
|
+
if (!stdout.isTTY) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
13
16
|
const handleResize = () => {
|
|
14
17
|
setSize({
|
|
15
18
|
rows: stdout.rows ?? DEFAULT_ROWS,
|
|
@@ -29,6 +32,23 @@ import { forwardRef, useEffect as useEffect2, useImperativeHandle, useMemo, useS
|
|
|
29
32
|
import { jsxDEV } from "react/jsx-dev-runtime";
|
|
30
33
|
var DEFAULT_HEIGHT = 10;
|
|
31
34
|
var DEFAULT_ITEM_HEIGHT = 1;
|
|
35
|
+
function getDefaultKey(item, index) {
|
|
36
|
+
if (item && typeof item === "object") {
|
|
37
|
+
const obj = item;
|
|
38
|
+
if ("id" in obj && (typeof obj.id === "string" || typeof obj.id === "number")) {
|
|
39
|
+
return String(obj.id);
|
|
40
|
+
}
|
|
41
|
+
if ("key" in obj && (typeof obj.key === "string" || typeof obj.key === "number")) {
|
|
42
|
+
return String(obj.key);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return String(index);
|
|
46
|
+
}
|
|
47
|
+
function validateItemHeight(itemHeight) {
|
|
48
|
+
if (!Number.isInteger(itemHeight) || itemHeight < 1) {
|
|
49
|
+
throw new Error(`[ink-virtual-list] itemHeight must be a positive integer, got: ${itemHeight}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
32
52
|
function calculateViewportOffset(selectedIndex, currentOffset, visibleCount) {
|
|
33
53
|
if (selectedIndex < currentOffset) {
|
|
34
54
|
return selectedIndex;
|
|
@@ -48,11 +68,13 @@ function VirtualListInner(props, ref) {
|
|
|
48
68
|
reservedLines = 0,
|
|
49
69
|
itemHeight = DEFAULT_ITEM_HEIGHT,
|
|
50
70
|
showOverflowIndicators = true,
|
|
71
|
+
overflowIndicatorThreshold = 1,
|
|
51
72
|
renderOverflowTop,
|
|
52
73
|
renderOverflowBottom,
|
|
53
74
|
renderScrollBar,
|
|
54
75
|
onViewportChange
|
|
55
76
|
} = props;
|
|
77
|
+
validateItemHeight(itemHeight);
|
|
56
78
|
const { rows: terminalRows } = useTerminalSize();
|
|
57
79
|
const resolvedHeight = useMemo(() => {
|
|
58
80
|
if (typeof height === "number") {
|
|
@@ -65,22 +87,21 @@ function VirtualListInner(props, ref) {
|
|
|
65
87
|
const availableHeight = Math.max(0, resolvedHeight - indicatorLines);
|
|
66
88
|
const visibleCount = Math.floor(availableHeight / resolvedItemHeight);
|
|
67
89
|
const clampedSelectedIndex = Math.max(0, Math.min(selectedIndex, items.length - 1));
|
|
68
|
-
const
|
|
90
|
+
const [viewportOffset, setViewportOffset] = useState2(() => {
|
|
69
91
|
if (items.length === 0)
|
|
70
92
|
return 0;
|
|
71
93
|
const maxOffset = Math.max(0, items.length - visibleCount);
|
|
72
|
-
let offset = 0;
|
|
73
94
|
if (clampedSelectedIndex >= visibleCount) {
|
|
74
|
-
|
|
95
|
+
return Math.min(clampedSelectedIndex - visibleCount + 1, maxOffset);
|
|
75
96
|
}
|
|
76
|
-
return
|
|
77
|
-
}
|
|
78
|
-
const [viewportOffset, setViewportOffset] = useState2(calculatedOffset);
|
|
97
|
+
return 0;
|
|
98
|
+
});
|
|
79
99
|
useEffect2(() => {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
100
|
+
const maxOffset = Math.max(0, items.length - visibleCount);
|
|
101
|
+
const targetOffset = calculateViewportOffset(clampedSelectedIndex, viewportOffset, visibleCount);
|
|
102
|
+
const clampedOffset = Math.min(Math.max(0, targetOffset), maxOffset);
|
|
103
|
+
if (clampedOffset !== viewportOffset) {
|
|
104
|
+
setViewportOffset(clampedOffset);
|
|
84
105
|
}
|
|
85
106
|
}, [clampedSelectedIndex, viewportOffset, visibleCount, items.length]);
|
|
86
107
|
const viewport = useMemo(() => ({
|
|
@@ -149,26 +170,32 @@ function VirtualListInner(props, ref) {
|
|
|
149
170
|
return /* @__PURE__ */ jsxDEV(Box, {
|
|
150
171
|
flexDirection: "column",
|
|
151
172
|
children: [
|
|
152
|
-
showOverflowIndicators && overflowTop
|
|
173
|
+
showOverflowIndicators && overflowTop >= overflowIndicatorThreshold && topIndicator(overflowTop),
|
|
153
174
|
visibleItems.map((item, idx) => {
|
|
154
175
|
const actualIndex = viewportOffset + idx;
|
|
155
|
-
const key = keyExtractor ? keyExtractor(item, actualIndex) :
|
|
176
|
+
const key = keyExtractor ? keyExtractor(item, actualIndex) : getDefaultKey(item, actualIndex);
|
|
156
177
|
const itemProps = {
|
|
157
178
|
item,
|
|
158
179
|
index: actualIndex,
|
|
159
180
|
isSelected: actualIndex === clampedSelectedIndex
|
|
160
181
|
};
|
|
161
182
|
return /* @__PURE__ */ jsxDEV(Box, {
|
|
183
|
+
height: resolvedItemHeight,
|
|
184
|
+
overflow: "hidden",
|
|
162
185
|
children: renderItem(itemProps)
|
|
163
186
|
}, key, false, undefined, this);
|
|
164
187
|
}),
|
|
165
|
-
showOverflowIndicators && overflowBottom
|
|
188
|
+
showOverflowIndicators && overflowBottom >= overflowIndicatorThreshold && bottomIndicator(overflowBottom),
|
|
166
189
|
renderScrollBar?.(viewport)
|
|
167
190
|
]
|
|
168
191
|
}, undefined, true, undefined, this);
|
|
169
192
|
}
|
|
170
193
|
var VirtualList = forwardRef(VirtualListInner);
|
|
171
194
|
export {
|
|
195
|
+
validateItemHeight,
|
|
172
196
|
useTerminalSize,
|
|
173
197
|
VirtualList
|
|
174
198
|
};
|
|
199
|
+
|
|
200
|
+
//# debugId=BD24055224F4B27D64756E2164756E21
|
|
201
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["src\\useTerminalSize.ts", "src\\VirtualList.tsx"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"import { useStdout } from \"ink\";\nimport { useEffect, useState } from \"react\";\n\n/**\n * Represents the terminal dimensions.\n */\nexport interface TerminalSize {\n /** Number of rows (lines) in the terminal */\n rows: number;\n /** Number of columns (characters) in the terminal */\n columns: number;\n}\n\nconst DEFAULT_ROWS = 24;\nconst DEFAULT_COLUMNS = 80;\n\n/**\n * Hook that returns the current terminal size and updates on resize.\n * Uses Ink's stdout to detect terminal dimensions.\n */\nexport function useTerminalSize(): TerminalSize {\n const { stdout } = useStdout();\n const [size, setSize] = useState<TerminalSize>({\n rows: stdout.rows ?? DEFAULT_ROWS,\n columns: stdout.columns ?? DEFAULT_COLUMNS,\n });\n\n useEffect(() => {\n // Skip resize listener in non-TTY environments (CI, pipes)\n if (!stdout.isTTY) {\n return;\n }\n\n const handleResize = () => {\n setSize({\n rows: stdout.rows ?? DEFAULT_ROWS,\n columns: stdout.columns ?? DEFAULT_COLUMNS,\n });\n };\n\n stdout.on(\"resize\", handleResize);\n return () => {\n stdout.off(\"resize\", handleResize);\n };\n }, [stdout]);\n\n return size;\n}\n",
|
|
6
|
+
"import { Box, Text } from \"ink\";\nimport { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from \"react\";\nimport type { RenderItemProps, ViewportState, VirtualListProps, VirtualListRef } from \"./types\";\nimport { useTerminalSize } from \"./useTerminalSize\";\n\nconst DEFAULT_HEIGHT = 10;\nconst DEFAULT_ITEM_HEIGHT = 1;\n\n/**\n * Attempts to extract a stable key from an item.\n * Checks for 'id' or 'key' properties on objects before falling back to index.\n */\nfunction getDefaultKey<T>(item: T, index: number): string {\n if (item && typeof item === \"object\") {\n const obj = item as Record<string, unknown>;\n if (\"id\" in obj && (typeof obj.id === \"string\" || typeof obj.id === \"number\")) {\n return String(obj.id);\n }\n if (\"key\" in obj && (typeof obj.key === \"string\" || typeof obj.key === \"number\")) {\n return String(obj.key);\n }\n }\n return String(index);\n}\n\nexport function validateItemHeight(itemHeight: number): void {\n if (!Number.isInteger(itemHeight) || itemHeight < 1) {\n throw new Error(`[ink-virtual-list] itemHeight must be a positive integer, got: ${itemHeight}`);\n }\n}\n\nfunction calculateViewportOffset(selectedIndex: number, currentOffset: number, visibleCount: number): number {\n // Selection above viewport - scroll up\n if (selectedIndex < currentOffset) {\n return selectedIndex;\n }\n\n // Selection below viewport - scroll down\n if (selectedIndex >= currentOffset + visibleCount) {\n return selectedIndex - visibleCount + 1;\n }\n\n // Selection visible - no change\n return currentOffset;\n}\n\nfunction VirtualListInner<T>(props: VirtualListProps<T>, ref: React.ForwardedRef<VirtualListRef>): React.JSX.Element {\n const {\n items,\n renderItem,\n selectedIndex = 0,\n keyExtractor,\n height = DEFAULT_HEIGHT,\n reservedLines = 0,\n itemHeight = DEFAULT_ITEM_HEIGHT,\n showOverflowIndicators = true,\n overflowIndicatorThreshold = 1,\n renderOverflowTop,\n renderOverflowBottom,\n renderScrollBar,\n onViewportChange,\n } = props;\n\n // Validate itemHeight\n validateItemHeight(itemHeight);\n\n const { rows: terminalRows } = useTerminalSize();\n\n // Calculate resolved height\n const resolvedHeight = useMemo(() => {\n if (typeof height === \"number\") {\n return height;\n }\n // 'auto' - use terminal rows minus reserved\n return Math.max(1, terminalRows - reservedLines);\n }, [height, terminalRows, reservedLines]);\n\n const resolvedItemHeight = itemHeight ?? DEFAULT_ITEM_HEIGHT;\n\n // Reserve space for overflow indicators within the height budget\n const indicatorLines = showOverflowIndicators ? 2 : 0;\n const availableHeight = Math.max(0, resolvedHeight - indicatorLines);\n\n // Calculate how many items fit in viewport\n const visibleCount = Math.floor(availableHeight / resolvedItemHeight);\n\n // Clamp selectedIndex to valid range\n const clampedSelectedIndex = Math.max(0, Math.min(selectedIndex, items.length - 1));\n\n // Lazy initial offset - positions selection at bottom of viewport if needed\n const [viewportOffset, setViewportOffset] = useState(() => {\n if (items.length === 0) return 0;\n const maxOffset = Math.max(0, items.length - visibleCount);\n if (clampedSelectedIndex >= visibleCount) {\n return Math.min(clampedSelectedIndex - visibleCount + 1, maxOffset);\n }\n return 0;\n });\n\n // Sync viewport when selection changes\n useEffect(() => {\n const maxOffset = Math.max(0, items.length - visibleCount);\n const targetOffset = calculateViewportOffset(clampedSelectedIndex, viewportOffset, visibleCount);\n const clampedOffset = Math.min(Math.max(0, targetOffset), maxOffset);\n if (clampedOffset !== viewportOffset) {\n setViewportOffset(clampedOffset);\n }\n }, [clampedSelectedIndex, viewportOffset, visibleCount, items.length]);\n\n // Build viewport state\n const viewport: ViewportState = useMemo(\n () => ({\n offset: viewportOffset,\n visibleCount,\n totalCount: items.length,\n }),\n [viewportOffset, visibleCount, items.length],\n );\n\n // Notify on viewport change\n useEffect(() => {\n onViewportChange?.(viewport);\n }, [viewport, onViewportChange]);\n\n // Imperative handle\n useImperativeHandle(\n ref,\n () => ({\n scrollToIndex: (index: number, alignment: \"auto\" | \"top\" | \"center\" | \"bottom\" = \"auto\") => {\n const clampedIndex = Math.max(0, Math.min(index, items.length - 1));\n let newOffset: number;\n\n switch (alignment) {\n case \"top\":\n newOffset = clampedIndex;\n break;\n case \"center\":\n newOffset = clampedIndex - Math.floor(visibleCount / 2);\n break;\n case \"bottom\":\n newOffset = clampedIndex - visibleCount + 1;\n break;\n default: // 'auto'\n newOffset = calculateViewportOffset(clampedIndex, viewportOffset, visibleCount);\n }\n\n const maxOffset = Math.max(0, items.length - visibleCount);\n setViewportOffset(Math.min(Math.max(0, newOffset), maxOffset));\n },\n getViewport: () => viewport,\n remeasure: () => {\n // Force recalculation by updating state\n setViewportOffset((prev) => {\n const maxOffset = Math.max(0, items.length - visibleCount);\n return Math.min(prev, maxOffset);\n });\n },\n }),\n [items.length, visibleCount, viewportOffset, viewport],\n );\n\n // Calculate overflow counts\n const overflowTop = viewportOffset;\n const overflowBottom = Math.max(0, items.length - viewportOffset - visibleCount);\n\n // Get visible items\n const visibleItems = items.slice(viewportOffset, viewportOffset + visibleCount);\n\n // Default overflow renderers (paddingLeft aligns with list content)\n const defaultOverflowTop = (count: number) => (\n <Box paddingLeft={2}>\n <Text dimColor>▲ {count} more</Text>\n </Box>\n );\n const defaultOverflowBottom = (count: number) => (\n <Box paddingLeft={2}>\n <Text dimColor>▼ {count} more</Text>\n </Box>\n );\n\n const topIndicator = renderOverflowTop ?? defaultOverflowTop;\n const bottomIndicator = renderOverflowBottom ?? defaultOverflowBottom;\n\n return (\n <Box flexDirection=\"column\">\n {showOverflowIndicators && overflowTop >= overflowIndicatorThreshold && topIndicator(overflowTop)}\n\n {visibleItems.map((item, idx) => {\n const actualIndex = viewportOffset + idx;\n const key = keyExtractor ? keyExtractor(item, actualIndex) : getDefaultKey(item, actualIndex);\n\n const itemProps: RenderItemProps<T> = {\n item,\n index: actualIndex,\n isSelected: actualIndex === clampedSelectedIndex,\n };\n\n return (\n <Box key={key} height={resolvedItemHeight} overflow=\"hidden\">\n {renderItem(itemProps)}\n </Box>\n );\n })}\n\n {showOverflowIndicators && overflowBottom >= overflowIndicatorThreshold && bottomIndicator(overflowBottom)}\n\n {renderScrollBar?.(viewport)}\n </Box>\n );\n}\n\nexport const VirtualList = forwardRef(VirtualListInner) as <T>(\n props: VirtualListProps<T> & { ref?: React.ForwardedRef<VirtualListRef> },\n) => ReturnType<typeof VirtualListInner>;\n"
|
|
7
|
+
],
|
|
8
|
+
"mappings": "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",
|
|
9
|
+
"debugId": "BD24055224F4B27D64756E2164756E21",
|
|
10
|
+
"names": []
|
|
11
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ink-virtual-list",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A virtualized list component for Ink terminal applications",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
7
|
-
"dist"
|
|
8
|
-
"src"
|
|
7
|
+
"dist"
|
|
9
8
|
],
|
|
10
9
|
"main": "./dist/index.js",
|
|
11
10
|
"module": "./dist/index.js",
|
|
@@ -18,6 +17,7 @@
|
|
|
18
17
|
"./package.json": "./package.json"
|
|
19
18
|
},
|
|
20
19
|
"scripts": {
|
|
20
|
+
"test": "bun test --only-failures",
|
|
21
21
|
"build": "bunup",
|
|
22
22
|
"typecheck": "tsc --noEmit",
|
|
23
23
|
"lint": "biome check .",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"@types/bun": "latest",
|
|
55
55
|
"@types/react": "^19.2.7",
|
|
56
56
|
"bunup": "^0.16.17",
|
|
57
|
+
"ink-testing-library": "^4.0.0",
|
|
57
58
|
"typescript": "^5.9.3"
|
|
58
59
|
}
|
|
59
60
|
}
|
package/src/VirtualList.tsx
DELETED
|
@@ -1,192 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
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
|
-
}
|
package/src/useTerminalSize.ts
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
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
|
-
}
|