Skip to content

Commit aff9274

Browse files
committed
Support overflow scrolling.
1 parent 927d748 commit aff9274

File tree

15 files changed

+1958
-152
lines changed

15 files changed

+1958
-152
lines changed

examples/scroll-into-view/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import React from 'react';
2+
import {render} from '../../src/index.js';
3+
import ScrollIntoView from './scroll-into-view.js';
4+
5+
render(React.createElement(ScrollIntoView));
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import React, {useState, useLayoutEffect, useCallback, useEffect} from 'react';
2+
import {
3+
Box,
4+
Text,
5+
useInput,
6+
measureElement,
7+
type DOMElement,
8+
Static,
9+
} from '../../src/index.js';
10+
11+
const items = Array.from({length: 100}).map((_, i) => ({
12+
id: String(i + 1),
13+
header: `Item ${i}`,
14+
content: ' - Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
15+
}));
16+
17+
type Rect = {
18+
width: number;
19+
height: number;
20+
x: number;
21+
y: number;
22+
};
23+
24+
function ScrollIntoView() {
25+
const [selected, setSelected] = useState(10);
26+
const [log, setLog] = useState<Array<{id: string; text: string}>>([]);
27+
const [relativeY, setRelativeY] = useState(0);
28+
const [scrollTop, setScrollTop] = useState(10);
29+
const [containerRect, setContainerRect] = useState<Rect | undefined>(null);
30+
const [itemRect, setItemRect] = useState<Rect | undefined>(null);
31+
const [container, setContainer] = useState<DOMElement | undefined>(null);
32+
const [selectedItemNode, setSelectedItemNode] = useState<
33+
DOMElement | undefined
34+
>(null);
35+
36+
const selectedItemCallbackReference = useCallback(
37+
(node: DOMElement | undefined) => {
38+
if (node) {
39+
setSelectedItemNode(node);
40+
}
41+
},
42+
[],
43+
);
44+
45+
useInput((_, key) => {
46+
if (key.upArrow) {
47+
setSelected(previous => Math.max(0, previous - 1));
48+
}
49+
50+
if (key.downArrow) {
51+
setSelected(previous => Math.min(items.length - 1, previous + 1));
52+
}
53+
});
54+
55+
useEffect(() => {
56+
setLog(previousLog => [
57+
...previousLog,
58+
{
59+
id: String(previousLog.length + 1),
60+
text: `Selected item is ${selected}`,
61+
},
62+
]);
63+
}, [selected]);
64+
65+
useLayoutEffect(() => {
66+
if (!container || !selectedItemNode) {
67+
return;
68+
}
69+
70+
const newContainerRect = measureElement(container);
71+
const newItemRect = measureElement(selectedItemNode);
72+
73+
setContainerRect(newContainerRect);
74+
setItemRect(newItemRect);
75+
76+
const unscrolledItemY =
77+
newItemRect.y -
78+
(newContainerRect.y + newContainerRect.borderTop) +
79+
scrollTop;
80+
setRelativeY(unscrolledItemY);
81+
82+
const visibleHeight = newContainerRect.innerHeight;
83+
84+
const isAbove = unscrolledItemY < scrollTop;
85+
const isBelow =
86+
unscrolledItemY + newItemRect.height > scrollTop + visibleHeight;
87+
88+
if (isAbove) {
89+
setScrollTop(unscrolledItemY);
90+
} else if (isBelow) {
91+
setScrollTop(unscrolledItemY + newItemRect.height - visibleHeight);
92+
}
93+
}, [scrollTop, container, selectedItemNode]);
94+
95+
// This example uses static to exercise subtle interactions between
96+
// useLayoutEffect and static rendering.
97+
return (
98+
<Box flexDirection="column">
99+
<Static items={log}>
100+
{item => (
101+
<Box key={item.id}>
102+
<Text>{item.text}</Text>
103+
</Box>
104+
)}
105+
</Static>
106+
107+
<Box flexDirection="column" padding={1}>
108+
<Text>Use up/down arrows to scroll. Selected: {selected}</Text>
109+
<Box
110+
ref={setContainer}
111+
borderStyle="round"
112+
overflowY="scroll"
113+
height={10}
114+
flexDirection="column"
115+
scrollTop={scrollTop}
116+
>
117+
<Box flexDirection="column" flexShrink={0} padding={1}>
118+
{items.map((item, index) => (
119+
<Box
120+
key={item.id}
121+
ref={
122+
selected === index ? selectedItemCallbackReference : undefined
123+
}
124+
flexDirection="column"
125+
>
126+
<Text color={selected === index ? 'green' : ''}>
127+
{item.header}
128+
{selected === index && ' ***'}
129+
</Text>
130+
<Text color={selected === index ? '' : 'gray'}>
131+
{item.content}
132+
</Text>
133+
</Box>
134+
))}
135+
</Box>
136+
</Box>
137+
</Box>
138+
</Box>
139+
);
140+
}
141+
142+
export default ScrollIntoView;

examples/scroll/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import React from 'react';
2+
import {render} from '../../src/index.js';
3+
import ScrollableContent from './scroll.js';
4+
5+
render(React.createElement(ScrollableContent));

examples/scroll/scroll.tsx

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import process from 'node:process';
2+
import React, {useState, useRef, useEffect, useLayoutEffect} from 'react';
3+
import {Box, Text, useInput, measureElement} from '../../src/index.js';
4+
5+
type ScrollMode = 'vertical' | 'horizontal' | 'both' | 'hidden';
6+
7+
const items = Array.from({length: 100}).map((_, i) => ({
8+
id: i,
9+
text: `Line ${i} ${'-'.repeat(i)}`,
10+
}));
11+
12+
export function useTerminalSize(): {columns: number; rows: number} {
13+
const [size, setSize] = useState({
14+
columns: process.stdout.columns || 80,
15+
rows: process.stdout.rows || 20,
16+
});
17+
18+
useEffect(() => {
19+
const updateSize = () => {
20+
setSize({
21+
columns: process.stdout.columns || 80,
22+
rows: process.stdout.rows || 20,
23+
});
24+
};
25+
26+
process.stdout.on('resize', updateSize);
27+
28+
return () => {
29+
process.stdout.off('resize', updateSize);
30+
};
31+
}, []);
32+
33+
return size;
34+
}
35+
36+
const scrollModes: ScrollMode[] = ['vertical', 'horizontal', 'both', 'hidden'];
37+
38+
function ScrollableContent() {
39+
const [scrollMode, setScrollMode] = useState<ScrollMode>('vertical');
40+
const [scrollTop, setScrollTop] = useState(0);
41+
const [scrollLeft, setScrollLeft] = useState(0);
42+
const reference = useRef<any>(null);
43+
const {columns, rows} = useTerminalSize();
44+
const [size, setSize] = useState({
45+
innerHeight: 0,
46+
scrollHeight: 0,
47+
innerWidth: 0,
48+
scrollWidth: 0,
49+
});
50+
51+
const sizeReference = useRef(size);
52+
useEffect(() => {
53+
sizeReference.current = size;
54+
}, [size]);
55+
56+
const scrollIntervalReference = useRef<NodeJS.Timeout | undefined>(null);
57+
58+
useEffect(() => {
59+
return () => {
60+
if (scrollIntervalReference.current) {
61+
clearInterval(scrollIntervalReference.current);
62+
}
63+
};
64+
}, []);
65+
66+
useLayoutEffect(() => {
67+
if (reference.current) {
68+
const {innerHeight, scrollHeight, innerWidth, scrollWidth} =
69+
measureElement(reference.current);
70+
71+
if (
72+
size.innerHeight !== innerHeight ||
73+
size.scrollHeight !== scrollHeight ||
74+
size.innerWidth !== innerWidth ||
75+
size.scrollWidth !== scrollWidth
76+
) {
77+
setSize({innerHeight, scrollHeight, innerWidth, scrollWidth});
78+
}
79+
}
80+
});
81+
82+
useInput((input, key) => {
83+
if (input === 'm') {
84+
setScrollMode(previousMode => {
85+
const currentIndex = scrollModes.indexOf(previousMode);
86+
const nextIndex = (currentIndex + 1) % scrollModes.length;
87+
return scrollModes[nextIndex]!;
88+
});
89+
return;
90+
}
91+
92+
if (!key.upArrow && !key.downArrow && !key.leftArrow && !key.rightArrow) {
93+
return;
94+
}
95+
96+
if (scrollIntervalReference.current) {
97+
clearInterval(scrollIntervalReference.current);
98+
}
99+
100+
const scroll = (
101+
setter: React.Dispatch<React.SetStateAction<number>>,
102+
getNewValue: (current: number) => number,
103+
) => {
104+
let frame = 0;
105+
const frames = 10;
106+
scrollIntervalReference.current = setInterval(() => {
107+
if (frame < frames) {
108+
setter(s => getNewValue(s));
109+
frame++;
110+
} else if (scrollIntervalReference.current) {
111+
clearInterval(scrollIntervalReference.current);
112+
scrollIntervalReference.current = null;
113+
}
114+
}, 16);
115+
};
116+
117+
if (key.upArrow) {
118+
scroll(setScrollTop, s => Math.max(0, s - 1));
119+
}
120+
121+
if (key.downArrow) {
122+
scroll(setScrollTop, s =>
123+
Math.min(
124+
s + 1,
125+
Math.max(
126+
0,
127+
sizeReference.current.scrollHeight -
128+
sizeReference.current.innerHeight,
129+
),
130+
),
131+
);
132+
}
133+
134+
if (key.leftArrow) {
135+
scroll(setScrollLeft, s => Math.max(0, s - 1));
136+
}
137+
138+
if (key.rightArrow) {
139+
scroll(setScrollLeft, s =>
140+
Math.min(
141+
s + 1,
142+
Math.max(
143+
0,
144+
sizeReference.current.scrollWidth - sizeReference.current.innerWidth,
145+
),
146+
),
147+
);
148+
}
149+
});
150+
151+
const overflowX =
152+
scrollMode === 'horizontal' || scrollMode === 'both' ? 'scroll' : 'hidden';
153+
const overflowY =
154+
scrollMode === 'vertical' || scrollMode === 'both' ? 'scroll' : 'hidden';
155+
156+
return (
157+
<Box flexDirection="column" height={rows - 2} width={columns}>
158+
<Box flexDirection="column" flexShrink={0} overflow="hidden">
159+
<Text>This is a demo showing a scrollable box.</Text>
160+
<Text>Press up/down arrow to scroll vertically.</Text>
161+
<Text>Press left/right arrow to scroll horizontally.</Text>
162+
<Text>
163+
Press 'm' to cycle through scroll modes (current: {scrollMode})
164+
</Text>
165+
<Text>ScrollTop: {scrollTop}</Text>
166+
<Text>ScrollLeft: {scrollLeft}</Text>
167+
<Text>
168+
Size: {size.innerWidth}x{size.innerHeight}
169+
</Text>
170+
<Text>
171+
Inner scrollable size: {size.scrollWidth}x{size.scrollHeight}
172+
</Text>
173+
</Box>
174+
<Box
175+
ref={reference}
176+
borderStyle="round"
177+
flexShrink={1}
178+
width="80%"
179+
flexDirection="column"
180+
overflowX={overflowX}
181+
overflowY={overflowY}
182+
paddingLeft={1}
183+
paddingRight={1}
184+
scrollTop={scrollTop}
185+
scrollLeft={scrollLeft}
186+
>
187+
<Box
188+
flexDirection="column"
189+
flexShrink={0}
190+
width={
191+
scrollMode === 'horizontal' || scrollMode === 'both' ? 120 : 'auto'
192+
}
193+
>
194+
{items.map(item => (
195+
<Text key={item.id}>{item.text}</Text>
196+
))}
197+
<Text key="last-line" color="yellow">
198+
This is the last line.
199+
</Text>
200+
</Box>
201+
</Box>
202+
<Box flexShrink={0} overflow="hidden">
203+
<Text>Example footer</Text>
204+
</Box>
205+
</Box>
206+
);
207+
}
208+
209+
export default ScrollableContent;

0 commit comments

Comments
 (0)