Support ink scrolling final pr (#12567)

This commit is contained in:
Jacob Richman
2025-11-11 07:50:11 -08:00
committed by GitHub
parent 7bb13d1c41
commit cbbf565121
43 changed files with 2498 additions and 1568 deletions

View File

@@ -12,6 +12,8 @@ import { ScrollProvider } from '../../contexts/ScrollProvider.js';
import { KeypressProvider } from '../../contexts/KeypressContext.js';
import { MouseProvider } from '../../contexts/MouseContext.js';
import { describe, it, expect, vi } from 'vitest';
import { waitFor } from '../../../test-utils/async.js';
// Mock useStdout to provide a fixed size for testing
vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
@@ -152,28 +154,20 @@ describe('ScrollableList Demo Behavior', () => {
await act(async () => {
addItem?.();
});
for (let i = 0; i < 20; i++) {
if (lastFrame!()?.includes('Count: 1001')) break;
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
}
await waitFor(() => {
expect(lastFrame!()).toContain('Count: 1001');
});
expect(lastFrame!()).toContain('Item 1001');
expect(lastFrame!()).toContain('Count: 1001');
expect(lastFrame!()).not.toContain('Item 990'); // Should have scrolled past it
// Add item 1002
await act(async () => {
addItem?.();
});
for (let i = 0; i < 20; i++) {
if (lastFrame!()?.includes('Count: 1002')) break;
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
}
await waitFor(() => {
expect(lastFrame!()).toContain('Count: 1002');
});
expect(lastFrame!()).toContain('Item 1002');
expect(lastFrame!()).toContain('Count: 1002');
expect(lastFrame!()).not.toContain('Item 991');
// Scroll up directly via ref
@@ -188,13 +182,103 @@ describe('ScrollableList Demo Behavior', () => {
await act(async () => {
addItem?.();
});
for (let i = 0; i < 20; i++) {
if (lastFrame!()?.includes('Count: 1003')) break;
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
}
await waitFor(() => {
expect(lastFrame!()).toContain('Count: 1003');
});
expect(lastFrame!()).not.toContain('Item 1003');
expect(lastFrame!()).toContain('Count: 1003');
});
it('should display sticky header when scrolled past the item', async () => {
let listRef: ScrollableListRef<Item> | null = null;
const StickyTestComponent = () => {
const items = Array.from({ length: 100 }, (_, i) => ({
id: String(i),
title: `Item ${i + 1}`,
}));
const ref = useRef<ScrollableListRef<Item>>(null);
useEffect(() => {
listRef = ref.current;
}, []);
return (
<MouseProvider mouseEventsEnabled={false}>
<KeypressProvider>
<ScrollProvider>
<Box flexDirection="column" width={80} height={10}>
<ScrollableList
ref={ref}
data={items}
renderItem={({ item, index }) => (
<Box flexDirection="column" height={3}>
{index === 0 ? (
<Box
sticky
stickyChildren={<Text>[STICKY] {item.title}</Text>}
>
<Text>[Normal] {item.title}</Text>
</Box>
) : (
<Text>[Normal] {item.title}</Text>
)}
<Text>Content for {item.title}</Text>
<Text>More content for {item.title}</Text>
</Box>
)}
estimatedItemHeight={() => 3}
keyExtractor={(item) => item.id}
hasFocus={true}
/>
</Box>
</ScrollProvider>
</KeypressProvider>
</MouseProvider>
);
};
let lastFrame: () => string | undefined;
await act(async () => {
const result = render(<StickyTestComponent />);
lastFrame = result.lastFrame;
});
// Initially at top, should see Normal Item 1
await waitFor(() => {
expect(lastFrame!()).toContain('[Normal] Item 1');
});
expect(lastFrame!()).not.toContain('[STICKY] Item 1');
// Scroll down slightly. Item 1 (height 3) is now partially off-screen (-2), so it should stick.
await act(async () => {
listRef?.scrollBy(2);
});
// Now Item 1 should be stuck
await waitFor(() => {
expect(lastFrame!()).toContain('[STICKY] Item 1');
});
expect(lastFrame!()).not.toContain('[Normal] Item 1');
// Scroll further down to unmount Item 1.
// Viewport height 10, item height 3. Scroll to 10.
// startIndex should be around 2, so Item 1 (index 0) is unmounted.
await act(async () => {
listRef?.scrollTo(10);
});
await waitFor(() => {
expect(lastFrame!()).not.toContain('[STICKY] Item 1');
});
// Scroll back to top
await act(async () => {
listRef?.scrollTo(0);
});
// Should be normal again
await waitFor(() => {
expect(lastFrame!()).toContain('[Normal] Item 1');
});
expect(lastFrame!()).not.toContain('[STICKY] Item 1');
});
});

View File

@@ -481,6 +481,7 @@ function VirtualizedList<T>(
width="100%"
height="100%"
flexDirection="column"
paddingRight={1}
>
<Box flexShrink={0} width="100%" flexDirection="column">
<Box height={topSpacerHeight} flexShrink={0} />