mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-06 11:21:15 -07:00
Scrollable support (#12544)
This commit is contained in:
55
packages/cli/src/ui/components/shared/Scrollable.test.tsx
Normal file
55
packages/cli/src/ui/components/shared/Scrollable.test.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { Scrollable } from './Scrollable.js';
|
||||
import { Text } from 'ink';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ink')>();
|
||||
return {
|
||||
...actual,
|
||||
getInnerHeight: vi.fn(() => 5),
|
||||
getScrollHeight: vi.fn(() => 10),
|
||||
getBoundingBox: vi.fn(() => ({ x: 0, y: 0, width: 10, height: 5 })),
|
||||
};
|
||||
});
|
||||
|
||||
describe('<Scrollable />', () => {
|
||||
it('renders children', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<Scrollable hasFocus={false} height={5}>
|
||||
<Text>Hello World</Text>
|
||||
</Scrollable>,
|
||||
);
|
||||
expect(lastFrame()).toContain('Hello World');
|
||||
});
|
||||
|
||||
it('renders multiple children', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<Scrollable hasFocus={false} height={5}>
|
||||
<Text>Line 1</Text>
|
||||
<Text>Line 2</Text>
|
||||
<Text>Line 3</Text>
|
||||
</Scrollable>,
|
||||
);
|
||||
expect(lastFrame()).toContain('Line 1');
|
||||
expect(lastFrame()).toContain('Line 2');
|
||||
expect(lastFrame()).toContain('Line 3');
|
||||
});
|
||||
|
||||
it('matches snapshot', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<Scrollable hasFocus={false} height={5}>
|
||||
<Text>Line 1</Text>
|
||||
<Text>Line 2</Text>
|
||||
<Text>Line 3</Text>
|
||||
</Scrollable>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
161
packages/cli/src/ui/components/shared/Scrollable.tsx
Normal file
161
packages/cli/src/ui/components/shared/Scrollable.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useLayoutEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { Box, getInnerHeight, getScrollHeight, type DOMElement } from 'ink';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
import { useScrollable } from '../../contexts/ScrollProvider.js';
|
||||
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
|
||||
|
||||
interface ScrollableProps {
|
||||
children?: React.ReactNode;
|
||||
width?: number;
|
||||
height?: number | string;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
hasFocus: boolean;
|
||||
scrollToBottom?: boolean;
|
||||
flexGrow?: number;
|
||||
}
|
||||
|
||||
export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
children,
|
||||
width,
|
||||
height,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
hasFocus,
|
||||
scrollToBottom,
|
||||
flexGrow,
|
||||
}) => {
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const ref = useRef<DOMElement>(null);
|
||||
const [size, setSize] = useState({
|
||||
innerHeight: 0,
|
||||
scrollHeight: 0,
|
||||
});
|
||||
const sizeRef = useRef(size);
|
||||
useEffect(() => {
|
||||
sizeRef.current = size;
|
||||
}, [size]);
|
||||
|
||||
const childrenCountRef = useRef(0);
|
||||
|
||||
// This effect needs to run on every render to correctly measure the container
|
||||
// and scroll to the bottom if new children are added. The if conditions
|
||||
// prevent infinite loops.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useLayoutEffect(() => {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
const innerHeight = Math.round(getInnerHeight(ref.current));
|
||||
const scrollHeight = Math.round(getScrollHeight(ref.current));
|
||||
|
||||
const isAtBottom = scrollTop >= size.scrollHeight - size.innerHeight - 1;
|
||||
|
||||
if (
|
||||
size.innerHeight !== innerHeight ||
|
||||
size.scrollHeight !== scrollHeight
|
||||
) {
|
||||
setSize({ innerHeight, scrollHeight });
|
||||
if (isAtBottom) {
|
||||
setScrollTop(Math.max(0, scrollHeight - innerHeight));
|
||||
}
|
||||
}
|
||||
|
||||
const childCountCurrent = React.Children.count(children);
|
||||
if (scrollToBottom && childrenCountRef.current !== childCountCurrent) {
|
||||
setScrollTop(Math.max(0, scrollHeight - innerHeight));
|
||||
}
|
||||
childrenCountRef.current = childCountCurrent;
|
||||
});
|
||||
|
||||
const scrollBy = useCallback(
|
||||
(delta: number) => {
|
||||
const { scrollHeight, innerHeight } = sizeRef.current;
|
||||
setScrollTop((prev: number) =>
|
||||
Math.min(
|
||||
Math.max(0, prev + delta),
|
||||
Math.max(0, scrollHeight - innerHeight),
|
||||
),
|
||||
);
|
||||
},
|
||||
[sizeRef],
|
||||
);
|
||||
|
||||
const { scrollbarColor, flashScrollbar, scrollByWithAnimation } =
|
||||
useAnimatedScrollbar(hasFocus, scrollBy);
|
||||
|
||||
useKeypress(
|
||||
(key: Key) => {
|
||||
if (key.shift) {
|
||||
if (key.name === 'up') {
|
||||
scrollByWithAnimation(-1);
|
||||
}
|
||||
if (key.name === 'down') {
|
||||
scrollByWithAnimation(1);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: hasFocus },
|
||||
);
|
||||
|
||||
const getScrollState = useCallback(
|
||||
() => ({
|
||||
scrollTop,
|
||||
scrollHeight: size.scrollHeight,
|
||||
innerHeight: size.innerHeight,
|
||||
}),
|
||||
[scrollTop, size.scrollHeight, size.innerHeight],
|
||||
);
|
||||
|
||||
const hasFocusCallback = useCallback(() => hasFocus, [hasFocus]);
|
||||
|
||||
const scrollableEntry = useMemo(
|
||||
() => ({
|
||||
ref: ref as React.RefObject<DOMElement>,
|
||||
getScrollState,
|
||||
scrollBy: scrollByWithAnimation,
|
||||
hasFocus: hasFocusCallback,
|
||||
flashScrollbar,
|
||||
}),
|
||||
[getScrollState, scrollByWithAnimation, hasFocusCallback, flashScrollbar],
|
||||
);
|
||||
|
||||
useScrollable(scrollableEntry, hasFocus && ref.current !== null);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
maxHeight={maxHeight}
|
||||
width={width ?? maxWidth}
|
||||
height={height}
|
||||
flexDirection="column"
|
||||
overflowY="scroll"
|
||||
overflowX="hidden"
|
||||
scrollTop={scrollTop}
|
||||
flexGrow={flexGrow}
|
||||
scrollbarThumbColor={scrollbarColor}
|
||||
>
|
||||
{/*
|
||||
This inner box is necessary to prevent the parent from shrinking
|
||||
based on the children's content. It also adds a right padding to
|
||||
make room for the scrollbar.
|
||||
*/}
|
||||
<Box flexShrink={0} paddingRight={1} flexDirection="column">
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
200
packages/cli/src/ui/components/shared/ScrollableList.test.tsx
Normal file
200
packages/cli/src/ui/components/shared/ScrollableList.test.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, act } from 'react';
|
||||
import { render } from 'ink-testing-library';
|
||||
import { Box, Text } from 'ink';
|
||||
import { ScrollableList, type ScrollableListRef } from './ScrollableList.js';
|
||||
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';
|
||||
// Mock useStdout to provide a fixed size for testing
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ink')>();
|
||||
return {
|
||||
...actual,
|
||||
useStdout: () => ({
|
||||
stdout: {
|
||||
columns: 80,
|
||||
rows: 24,
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
write: vi.fn(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
interface Item {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const getLorem = (index: number) =>
|
||||
Array(10)
|
||||
.fill(null)
|
||||
.map(() => 'lorem ipsum '.repeat((index % 3) + 1).trim())
|
||||
.join('\n');
|
||||
|
||||
const TestComponent = ({
|
||||
initialItems = 1000,
|
||||
onAddItem,
|
||||
onRef,
|
||||
}: {
|
||||
initialItems?: number;
|
||||
onAddItem?: (addItem: () => void) => void;
|
||||
onRef?: (ref: ScrollableListRef<Item> | null) => void;
|
||||
}) => {
|
||||
const [items, setItems] = useState<Item[]>(() =>
|
||||
Array.from({ length: initialItems }, (_, i) => ({
|
||||
id: String(i),
|
||||
title: `Item ${i + 1}`,
|
||||
})),
|
||||
);
|
||||
|
||||
const listRef = useRef<ScrollableListRef<Item>>(null);
|
||||
|
||||
useEffect(() => {
|
||||
onAddItem?.(() => {
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: String(prev.length),
|
||||
title: `Item ${prev.length + 1}`,
|
||||
},
|
||||
]);
|
||||
});
|
||||
}, [onAddItem]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onRef) {
|
||||
onRef(listRef.current);
|
||||
}
|
||||
}, [onRef]);
|
||||
|
||||
return (
|
||||
<MouseProvider mouseEventsEnabled={false}>
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ScrollProvider>
|
||||
<Box flexDirection="column" width={80} height={24} padding={1}>
|
||||
<Box flexGrow={1} borderStyle="round" borderColor="cyan">
|
||||
<ScrollableList
|
||||
ref={listRef}
|
||||
data={items}
|
||||
renderItem={({ item, index }) => (
|
||||
<Box flexDirection="column" paddingBottom={2}>
|
||||
<Box
|
||||
sticky
|
||||
flexDirection="column"
|
||||
width={78}
|
||||
opaque
|
||||
stickyChildren={
|
||||
<Box flexDirection="column" width={78} opaque>
|
||||
<Text>{item.title}</Text>
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderTop={true}
|
||||
borderBottom={false}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
borderColor="gray"
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Text>{item.title}</Text>
|
||||
</Box>
|
||||
<Text color="gray">{getLorem(index)}</Text>
|
||||
</Box>
|
||||
)}
|
||||
estimatedItemHeight={() => 14}
|
||||
keyExtractor={(item) => item.id}
|
||||
hasFocus={true}
|
||||
initialScrollIndex={Number.MAX_SAFE_INTEGER}
|
||||
/>
|
||||
</Box>
|
||||
<Text>Count: {items.length}</Text>
|
||||
</Box>
|
||||
</ScrollProvider>
|
||||
</KeypressProvider>
|
||||
</MouseProvider>
|
||||
);
|
||||
};
|
||||
describe('ScrollableList Demo Behavior', () => {
|
||||
it('should scroll to bottom when new items are added and stop when scrolled up', async () => {
|
||||
let addItem: (() => void) | undefined;
|
||||
let listRef: ScrollableListRef<Item> | null = null;
|
||||
let lastFrame: () => string | undefined;
|
||||
|
||||
await act(async () => {
|
||||
const result = render(
|
||||
<TestComponent
|
||||
onAddItem={(add) => {
|
||||
addItem = add;
|
||||
}}
|
||||
onRef={(ref) => {
|
||||
listRef = ref;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
lastFrame = result.lastFrame;
|
||||
});
|
||||
|
||||
// Initial render should show Item 1000
|
||||
expect(lastFrame!()).toContain('Item 1000');
|
||||
expect(lastFrame!()).toContain('Count: 1000');
|
||||
|
||||
// Add item 1001
|
||||
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));
|
||||
});
|
||||
}
|
||||
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));
|
||||
});
|
||||
}
|
||||
expect(lastFrame!()).toContain('Item 1002');
|
||||
expect(lastFrame!()).toContain('Count: 1002');
|
||||
expect(lastFrame!()).not.toContain('Item 991');
|
||||
|
||||
// Scroll up directly via ref
|
||||
await act(async () => {
|
||||
listRef?.scrollBy(-5);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
});
|
||||
|
||||
// Add item 1003 - should NOT be visible because we scrolled up
|
||||
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));
|
||||
});
|
||||
}
|
||||
expect(lastFrame!()).not.toContain('Item 1003');
|
||||
expect(lastFrame!()).toContain('Count: 1003');
|
||||
});
|
||||
});
|
||||
131
packages/cli/src/ui/components/shared/ScrollableList.tsx
Normal file
131
packages/cli/src/ui/components/shared/ScrollableList.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
useRef,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import type React from 'react';
|
||||
import { VirtualizedList, type VirtualizedListRef } from './VirtualizedList.js';
|
||||
import { useScrollable } from '../../contexts/ScrollProvider.js';
|
||||
import { Box, type DOMElement } from 'ink';
|
||||
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
|
||||
type VirtualizedListProps<T> = {
|
||||
data: T[];
|
||||
renderItem: (info: { item: T; index: number }) => React.ReactElement;
|
||||
estimatedItemHeight: (index: number) => number;
|
||||
keyExtractor: (item: T, index: number) => string;
|
||||
initialScrollIndex?: number;
|
||||
initialScrollOffsetInIndex?: number;
|
||||
};
|
||||
|
||||
interface ScrollableListProps<T> extends VirtualizedListProps<T> {
|
||||
hasFocus: boolean;
|
||||
}
|
||||
|
||||
export type ScrollableListRef<T> = VirtualizedListRef<T>;
|
||||
|
||||
function ScrollableList<T>(
|
||||
props: ScrollableListProps<T>,
|
||||
ref: React.Ref<ScrollableListRef<T>>,
|
||||
) {
|
||||
const { hasFocus } = props;
|
||||
const virtualizedListRef = useRef<VirtualizedListRef<T>>(null);
|
||||
const containerRef = useRef<DOMElement>(null);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
scrollBy: (delta) => virtualizedListRef.current?.scrollBy(delta),
|
||||
scrollTo: (offset) => virtualizedListRef.current?.scrollTo(offset),
|
||||
scrollToEnd: () => virtualizedListRef.current?.scrollToEnd(),
|
||||
scrollToIndex: (params) =>
|
||||
virtualizedListRef.current?.scrollToIndex(params),
|
||||
scrollToItem: (params) =>
|
||||
virtualizedListRef.current?.scrollToItem(params),
|
||||
getScrollIndex: () => virtualizedListRef.current?.getScrollIndex() ?? 0,
|
||||
getScrollState: () =>
|
||||
virtualizedListRef.current?.getScrollState() ?? {
|
||||
scrollTop: 0,
|
||||
scrollHeight: 0,
|
||||
innerHeight: 0,
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const getScrollState = useCallback(
|
||||
() =>
|
||||
virtualizedListRef.current?.getScrollState() ?? {
|
||||
scrollTop: 0,
|
||||
scrollHeight: 0,
|
||||
innerHeight: 0,
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const scrollBy = useCallback((delta: number) => {
|
||||
virtualizedListRef.current?.scrollBy(delta);
|
||||
}, []);
|
||||
|
||||
const { scrollbarColor, flashScrollbar, scrollByWithAnimation } =
|
||||
useAnimatedScrollbar(hasFocus, scrollBy);
|
||||
|
||||
useKeypress(
|
||||
(key: Key) => {
|
||||
if (key.shift) {
|
||||
if (key.name === 'up') {
|
||||
scrollByWithAnimation(-1);
|
||||
}
|
||||
if (key.name === 'down') {
|
||||
scrollByWithAnimation(1);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: hasFocus },
|
||||
);
|
||||
|
||||
const hasFocusCallback = useCallback(() => hasFocus, [hasFocus]);
|
||||
|
||||
const scrollableEntry = useMemo(
|
||||
() => ({
|
||||
ref: containerRef as React.RefObject<DOMElement>,
|
||||
getScrollState,
|
||||
scrollBy: scrollByWithAnimation,
|
||||
hasFocus: hasFocusCallback,
|
||||
flashScrollbar,
|
||||
}),
|
||||
[getScrollState, scrollByWithAnimation, hasFocusCallback, flashScrollbar],
|
||||
);
|
||||
|
||||
useScrollable(scrollableEntry, hasFocus);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
overflow="hidden"
|
||||
>
|
||||
<VirtualizedList
|
||||
ref={virtualizedListRef}
|
||||
{...props}
|
||||
scrollbarThumbColor={scrollbarColor}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const ScrollableListWithForwardRef = forwardRef(ScrollableList) as <T>(
|
||||
props: ScrollableListProps<T> & { ref?: React.Ref<ScrollableListRef<T>> },
|
||||
) => React.ReactElement;
|
||||
|
||||
export { ScrollableListWithForwardRef as ScrollableList };
|
||||
283
packages/cli/src/ui/components/shared/VirtualizedList.test.tsx
Normal file
283
packages/cli/src/ui/components/shared/VirtualizedList.test.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { VirtualizedList, type VirtualizedListRef } from './VirtualizedList.js';
|
||||
import { Text, Box } from 'ink';
|
||||
import {
|
||||
createRef,
|
||||
act,
|
||||
useEffect,
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
describe('<VirtualizedList />', () => {
|
||||
const keyExtractor = (item: string) => item;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('with 10px height and 100 items', () => {
|
||||
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
|
||||
// We use 1px for items. Container is 10px.
|
||||
// Viewport shows 10 items. Overscan adds 10 items.
|
||||
const itemHeight = 1;
|
||||
const renderItem1px = ({ item }: { item: string }) => (
|
||||
<Box height={itemHeight}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'top',
|
||||
initialScrollIndex: undefined,
|
||||
visible: ['Item 0', 'Item 7'],
|
||||
notVisible: ['Item 8', 'Item 15', 'Item 50', 'Item 99'],
|
||||
},
|
||||
{
|
||||
name: 'scrolled to bottom',
|
||||
initialScrollIndex: 99,
|
||||
visible: ['Item 99', 'Item 92'],
|
||||
notVisible: ['Item 91', 'Item 85', 'Item 50', 'Item 0'],
|
||||
},
|
||||
])(
|
||||
'renders only visible items ($name)',
|
||||
async ({ initialScrollIndex, visible, notVisible }) => {
|
||||
const { lastFrame } = render(
|
||||
<Box height={10} width={100} borderStyle="round">
|
||||
<VirtualizedList
|
||||
data={longData}
|
||||
renderItem={renderItem1px}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => itemHeight}
|
||||
initialScrollIndex={initialScrollIndex}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
const frame = lastFrame();
|
||||
visible.forEach((item) => {
|
||||
expect(frame).toContain(item);
|
||||
});
|
||||
notVisible.forEach((item) => {
|
||||
expect(frame).not.toContain(item);
|
||||
});
|
||||
expect(frame).toMatchSnapshot();
|
||||
},
|
||||
);
|
||||
|
||||
it('sticks to bottom when new items added', async () => {
|
||||
const { lastFrame, rerender } = render(
|
||||
<Box height={10} width={100} borderStyle="round">
|
||||
<VirtualizedList
|
||||
data={longData}
|
||||
renderItem={renderItem1px}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => itemHeight}
|
||||
initialScrollIndex={99}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain('Item 99');
|
||||
|
||||
// Add items
|
||||
const newData = [...longData, 'Item 100', 'Item 101'];
|
||||
rerender(
|
||||
<Box height={10} width={100} borderStyle="round">
|
||||
<VirtualizedList
|
||||
data={newData}
|
||||
renderItem={renderItem1px}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => itemHeight}
|
||||
// We don't need to pass initialScrollIndex again for it to stick,
|
||||
// but passing it doesn't hurt. The component should auto-stick because it was at bottom.
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Item 101');
|
||||
expect(frame).not.toContain('Item 0');
|
||||
});
|
||||
|
||||
it('scrolls down to show new items when requested via ref', async () => {
|
||||
const ref = createRef<VirtualizedListRef<string>>();
|
||||
const { lastFrame } = render(
|
||||
<Box height={10} width={100} borderStyle="round">
|
||||
<VirtualizedList
|
||||
ref={ref}
|
||||
data={longData}
|
||||
renderItem={renderItem1px}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => itemHeight}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain('Item 0');
|
||||
|
||||
// Scroll to bottom via ref
|
||||
await act(async () => {
|
||||
ref.current?.scrollToEnd();
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Item 99');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ initialScrollIndex: 0, expectedMountedCount: 5 },
|
||||
{ initialScrollIndex: 500, expectedMountedCount: 6 },
|
||||
{ initialScrollIndex: 999, expectedMountedCount: 5 },
|
||||
])(
|
||||
'mounts only visible items with 1000 items and 10px height (scroll: $initialScrollIndex)',
|
||||
async ({ initialScrollIndex, expectedMountedCount }) => {
|
||||
let mountedCount = 0;
|
||||
const tallItemHeight = 5;
|
||||
const ItemWithEffect = ({ item }: { item: string }) => {
|
||||
useEffect(() => {
|
||||
mountedCount++;
|
||||
return () => {
|
||||
mountedCount--;
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<Box height={tallItemHeight}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const veryLongData = Array.from(
|
||||
{ length: 1000 },
|
||||
(_, i) => `Item ${i}`,
|
||||
);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<Box height={20} width={100} borderStyle="round">
|
||||
<VirtualizedList
|
||||
data={veryLongData}
|
||||
renderItem={({ item }) => (
|
||||
<ItemWithEffect key={item} item={item} />
|
||||
)}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => tallItemHeight}
|
||||
initialScrollIndex={initialScrollIndex}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
const frame = lastFrame();
|
||||
expect(mountedCount).toBe(expectedMountedCount);
|
||||
expect(frame).toMatchSnapshot();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('renders more items when a visible item shrinks via context update', async () => {
|
||||
const SizeContext = createContext<{
|
||||
firstItemHeight: number;
|
||||
setFirstItemHeight: (h: number) => void;
|
||||
}>({
|
||||
firstItemHeight: 10,
|
||||
setFirstItemHeight: () => {},
|
||||
});
|
||||
|
||||
const items = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `Item ${i}`,
|
||||
}));
|
||||
|
||||
const ItemWithContext = ({
|
||||
item,
|
||||
index,
|
||||
}: {
|
||||
item: { id: string };
|
||||
index: number;
|
||||
}) => {
|
||||
const { firstItemHeight } = useContext(SizeContext);
|
||||
const height = index === 0 ? firstItemHeight : 1;
|
||||
return (
|
||||
<Box height={height}>
|
||||
<Text>{item.id}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const TestComponent = () => {
|
||||
const [firstItemHeight, setFirstItemHeight] = useState(10);
|
||||
return (
|
||||
<SizeContext.Provider value={{ firstItemHeight, setFirstItemHeight }}>
|
||||
<Box height={10} width={100}>
|
||||
<VirtualizedList
|
||||
data={items}
|
||||
renderItem={({ item, index }) => (
|
||||
<ItemWithContext item={item} index={index} />
|
||||
)}
|
||||
keyExtractor={(item) => item.id}
|
||||
estimatedItemHeight={() => 1}
|
||||
/>
|
||||
</Box>
|
||||
{/* Expose setter for testing */}
|
||||
<TestControl setFirstItemHeight={setFirstItemHeight} />
|
||||
</SizeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
let setHeightFn: (h: number) => void = () => {};
|
||||
const TestControl = ({
|
||||
setFirstItemHeight,
|
||||
}: {
|
||||
setFirstItemHeight: (h: number) => void;
|
||||
}) => {
|
||||
setHeightFn = setFirstItemHeight;
|
||||
return null;
|
||||
};
|
||||
|
||||
const { lastFrame } = render(<TestComponent />);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
// Initially, only Item 0 (height 10) fills the 10px viewport
|
||||
expect(lastFrame()).toContain('Item 0');
|
||||
expect(lastFrame()).not.toContain('Item 1');
|
||||
|
||||
// Shrink Item 0 to 1px via context
|
||||
await act(async () => {
|
||||
setHeightFn(1);
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
// Now Item 0 is 1px, so Items 1-9 should also be visible to fill 10px
|
||||
expect(lastFrame()).toContain('Item 0');
|
||||
expect(lastFrame()).toContain('Item 1');
|
||||
expect(lastFrame()).toContain('Item 9');
|
||||
});
|
||||
});
|
||||
492
packages/cli/src/ui/components/shared/VirtualizedList.tsx
Normal file
492
packages/cli/src/ui/components/shared/VirtualizedList.tsx
Normal file
@@ -0,0 +1,492 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
useState,
|
||||
useRef,
|
||||
useLayoutEffect,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import type React from 'react';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
||||
import { type DOMElement, measureElement, Box } from 'ink';
|
||||
|
||||
export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
type VirtualizedListProps<T> = {
|
||||
data: T[];
|
||||
renderItem: (info: { item: T; index: number }) => React.ReactElement;
|
||||
estimatedItemHeight: (index: number) => number;
|
||||
keyExtractor: (item: T, index: number) => string;
|
||||
initialScrollIndex?: number;
|
||||
initialScrollOffsetInIndex?: number;
|
||||
scrollbarThumbColor?: string;
|
||||
};
|
||||
|
||||
export type VirtualizedListRef<T> = {
|
||||
scrollBy: (delta: number) => void;
|
||||
scrollTo: (offset: number) => void;
|
||||
scrollToEnd: () => void;
|
||||
scrollToIndex: (params: {
|
||||
index: number;
|
||||
viewOffset?: number;
|
||||
viewPosition?: number;
|
||||
}) => void;
|
||||
scrollToItem: (params: {
|
||||
item: T;
|
||||
viewOffset?: number;
|
||||
viewPosition?: number;
|
||||
}) => void;
|
||||
getScrollIndex: () => number;
|
||||
getScrollState: () => {
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
innerHeight: number;
|
||||
};
|
||||
};
|
||||
|
||||
function findLastIndex<T>(
|
||||
array: T[],
|
||||
predicate: (value: T, index: number, obj: T[]) => unknown,
|
||||
): number {
|
||||
for (let i = array.length - 1; i >= 0; i--) {
|
||||
if (predicate(array[i]!, i, array)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function VirtualizedList<T>(
|
||||
props: VirtualizedListProps<T>,
|
||||
ref: React.Ref<VirtualizedListRef<T>>,
|
||||
) {
|
||||
const {
|
||||
data,
|
||||
renderItem,
|
||||
estimatedItemHeight,
|
||||
keyExtractor,
|
||||
initialScrollIndex,
|
||||
initialScrollOffsetInIndex,
|
||||
} = props;
|
||||
const dataRef = useRef(data);
|
||||
useEffect(() => {
|
||||
dataRef.current = data;
|
||||
}, [data]);
|
||||
|
||||
const [scrollAnchor, setScrollAnchor] = useState(() => {
|
||||
const scrollToEnd =
|
||||
initialScrollIndex === SCROLL_TO_ITEM_END ||
|
||||
(typeof initialScrollIndex === 'number' &&
|
||||
initialScrollIndex >= data.length - 1 &&
|
||||
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
|
||||
|
||||
if (scrollToEnd) {
|
||||
return {
|
||||
index: data.length > 0 ? data.length - 1 : 0,
|
||||
offset: SCROLL_TO_ITEM_END,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof initialScrollIndex === 'number') {
|
||||
return {
|
||||
index: Math.max(0, Math.min(data.length - 1, initialScrollIndex)),
|
||||
offset: initialScrollOffsetInIndex ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
return { index: 0, offset: 0 };
|
||||
});
|
||||
const [isStickingToBottom, setIsStickingToBottom] = useState(() => {
|
||||
const scrollToEnd =
|
||||
initialScrollIndex === SCROLL_TO_ITEM_END ||
|
||||
(typeof initialScrollIndex === 'number' &&
|
||||
initialScrollIndex >= data.length - 1 &&
|
||||
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
|
||||
return scrollToEnd;
|
||||
});
|
||||
const containerRef = useRef<DOMElement>(null);
|
||||
const [containerHeight, setContainerHeight] = useState(0);
|
||||
const itemRefs = useRef<Array<DOMElement | null>>([]);
|
||||
const [heights, setHeights] = useState<number[]>([]);
|
||||
const isInitialScrollSet = useRef(false);
|
||||
|
||||
const { totalHeight, offsets } = useMemo(() => {
|
||||
const offsets: number[] = [0];
|
||||
let totalHeight = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const height = heights[i] ?? estimatedItemHeight(i);
|
||||
totalHeight += height;
|
||||
offsets.push(totalHeight);
|
||||
}
|
||||
return { totalHeight, offsets };
|
||||
}, [heights, data, estimatedItemHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
setHeights((prevHeights) => {
|
||||
if (data.length === prevHeights.length) {
|
||||
return prevHeights;
|
||||
}
|
||||
|
||||
const newHeights = [...prevHeights];
|
||||
if (data.length < prevHeights.length) {
|
||||
newHeights.length = data.length;
|
||||
} else {
|
||||
for (let i = prevHeights.length; i < data.length; i++) {
|
||||
newHeights[i] = estimatedItemHeight(i);
|
||||
}
|
||||
}
|
||||
return newHeights;
|
||||
});
|
||||
}, [data, estimatedItemHeight]);
|
||||
|
||||
// This layout effect needs to run on every render to correctly measure the
|
||||
// container and ensure we recompute the layout if it has changed.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useLayoutEffect(() => {
|
||||
if (containerRef.current) {
|
||||
const height = Math.round(measureElement(containerRef.current).height);
|
||||
if (containerHeight !== height) {
|
||||
setContainerHeight(height);
|
||||
}
|
||||
}
|
||||
|
||||
let newHeights: number[] | null = null;
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
const itemRef = itemRefs.current[i];
|
||||
if (itemRef) {
|
||||
const height = Math.round(measureElement(itemRef).height);
|
||||
if (height !== heights[i]) {
|
||||
if (!newHeights) {
|
||||
newHeights = [...heights];
|
||||
}
|
||||
newHeights[i] = height;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (newHeights) {
|
||||
setHeights(newHeights);
|
||||
}
|
||||
});
|
||||
|
||||
const scrollableContainerHeight = containerRef.current
|
||||
? Math.round(measureElement(containerRef.current).height)
|
||||
: containerHeight;
|
||||
|
||||
const getAnchorForScrollTop = useCallback(
|
||||
(
|
||||
scrollTop: number,
|
||||
offsets: number[],
|
||||
): { index: number; offset: number } => {
|
||||
const index = findLastIndex(offsets, (offset) => offset <= scrollTop);
|
||||
if (index === -1) {
|
||||
return { index: 0, offset: 0 };
|
||||
}
|
||||
|
||||
return { index, offset: scrollTop - offsets[index]! };
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const scrollTop = useMemo(() => {
|
||||
const offset = offsets[scrollAnchor.index];
|
||||
if (typeof offset !== 'number') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (scrollAnchor.offset === SCROLL_TO_ITEM_END) {
|
||||
const itemHeight = heights[scrollAnchor.index] ?? 0;
|
||||
return offset + itemHeight - scrollableContainerHeight;
|
||||
}
|
||||
|
||||
return offset + scrollAnchor.offset;
|
||||
}, [scrollAnchor, offsets, heights, scrollableContainerHeight]);
|
||||
|
||||
const prevDataLength = useRef(data.length);
|
||||
const prevTotalHeight = useRef(totalHeight);
|
||||
const prevScrollTop = useRef(scrollTop);
|
||||
const prevContainerHeight = useRef(scrollableContainerHeight);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const contentPreviouslyFit =
|
||||
prevTotalHeight.current <= prevContainerHeight.current;
|
||||
const wasScrolledToBottomPixels =
|
||||
prevScrollTop.current >=
|
||||
prevTotalHeight.current - prevContainerHeight.current - 1;
|
||||
const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels;
|
||||
|
||||
// If the user was at the bottom, they are now sticking. This handles
|
||||
// manually scrolling back to the bottom.
|
||||
if (wasAtBottom && scrollTop >= prevScrollTop.current) {
|
||||
setIsStickingToBottom(true);
|
||||
}
|
||||
|
||||
const listGrew = data.length > prevDataLength.current;
|
||||
const containerChanged =
|
||||
prevContainerHeight.current !== scrollableContainerHeight;
|
||||
|
||||
// We scroll to the end if:
|
||||
// 1. The list grew AND we were already at the bottom (or sticking).
|
||||
// 2. We are sticking to the bottom AND the container size changed.
|
||||
if (
|
||||
(listGrew && (isStickingToBottom || wasAtBottom)) ||
|
||||
(isStickingToBottom && containerChanged)
|
||||
) {
|
||||
setScrollAnchor({
|
||||
index: data.length > 0 ? data.length - 1 : 0,
|
||||
offset: SCROLL_TO_ITEM_END,
|
||||
});
|
||||
// If we are scrolling to the bottom, we are by definition sticking.
|
||||
if (!isStickingToBottom) {
|
||||
setIsStickingToBottom(true);
|
||||
}
|
||||
}
|
||||
// Scenario 2: The list has changed (shrunk) in a way that our
|
||||
// current scroll position or anchor is invalid. We should adjust to the bottom.
|
||||
else if (
|
||||
(scrollAnchor.index >= data.length ||
|
||||
scrollTop > totalHeight - scrollableContainerHeight) &&
|
||||
data.length > 0
|
||||
) {
|
||||
const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
|
||||
} else if (data.length === 0) {
|
||||
// List is now empty, reset scroll to top.
|
||||
setScrollAnchor({ index: 0, offset: 0 });
|
||||
}
|
||||
|
||||
// Update refs for the next render cycle.
|
||||
prevDataLength.current = data.length;
|
||||
prevTotalHeight.current = totalHeight;
|
||||
prevScrollTop.current = scrollTop;
|
||||
prevContainerHeight.current = scrollableContainerHeight;
|
||||
}, [
|
||||
data.length,
|
||||
totalHeight,
|
||||
scrollTop,
|
||||
scrollableContainerHeight,
|
||||
scrollAnchor.index,
|
||||
getAnchorForScrollTop,
|
||||
offsets,
|
||||
isStickingToBottom,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (
|
||||
isInitialScrollSet.current ||
|
||||
offsets.length <= 1 ||
|
||||
totalHeight <= 0 ||
|
||||
containerHeight <= 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof initialScrollIndex === 'number') {
|
||||
const scrollToEnd =
|
||||
initialScrollIndex === SCROLL_TO_ITEM_END ||
|
||||
(initialScrollIndex >= data.length - 1 &&
|
||||
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
|
||||
|
||||
if (scrollToEnd) {
|
||||
setScrollAnchor({
|
||||
index: data.length - 1,
|
||||
offset: SCROLL_TO_ITEM_END,
|
||||
});
|
||||
setIsStickingToBottom(true);
|
||||
isInitialScrollSet.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const index = Math.max(0, Math.min(data.length - 1, initialScrollIndex));
|
||||
const offset = initialScrollOffsetInIndex ?? 0;
|
||||
const newScrollTop = (offsets[index] ?? 0) + offset;
|
||||
|
||||
const clampedScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(totalHeight - scrollableContainerHeight, newScrollTop),
|
||||
);
|
||||
|
||||
setScrollAnchor(getAnchorForScrollTop(clampedScrollTop, offsets));
|
||||
isInitialScrollSet.current = true;
|
||||
}
|
||||
}, [
|
||||
initialScrollIndex,
|
||||
initialScrollOffsetInIndex,
|
||||
offsets,
|
||||
totalHeight,
|
||||
containerHeight,
|
||||
getAnchorForScrollTop,
|
||||
data.length,
|
||||
heights,
|
||||
scrollableContainerHeight,
|
||||
]);
|
||||
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
findLastIndex(offsets, (offset) => offset <= scrollTop) - 1,
|
||||
);
|
||||
const endIndexOffset = offsets.findIndex(
|
||||
(offset) => offset > scrollTop + scrollableContainerHeight,
|
||||
);
|
||||
const endIndex =
|
||||
endIndexOffset === -1
|
||||
? data.length - 1
|
||||
: Math.min(data.length - 1, endIndexOffset);
|
||||
|
||||
const topSpacerHeight = offsets[startIndex] ?? 0;
|
||||
const bottomSpacerHeight =
|
||||
totalHeight - (offsets[endIndex + 1] ?? totalHeight);
|
||||
|
||||
const renderedItems = [];
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
const item = data[i];
|
||||
if (item) {
|
||||
renderedItems.push(
|
||||
<Box
|
||||
key={keyExtractor(item, i)}
|
||||
width="100%"
|
||||
ref={(el) => {
|
||||
itemRefs.current[i] = el;
|
||||
}}
|
||||
>
|
||||
{renderItem({ item, index: i })}
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
scrollBy: (delta: number) => {
|
||||
if (delta < 0) {
|
||||
setIsStickingToBottom(false);
|
||||
}
|
||||
const currentScrollTop = scrollTop;
|
||||
const newScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
totalHeight - scrollableContainerHeight,
|
||||
currentScrollTop + delta,
|
||||
),
|
||||
);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
|
||||
},
|
||||
scrollTo: (offset: number) => {
|
||||
setIsStickingToBottom(false);
|
||||
const newScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(totalHeight - scrollableContainerHeight, offset),
|
||||
);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
|
||||
},
|
||||
scrollToEnd: () => {
|
||||
setIsStickingToBottom(true);
|
||||
if (data.length > 0) {
|
||||
setScrollAnchor({
|
||||
index: data.length - 1,
|
||||
offset: SCROLL_TO_ITEM_END,
|
||||
});
|
||||
}
|
||||
},
|
||||
scrollToIndex: ({
|
||||
index,
|
||||
viewOffset = 0,
|
||||
viewPosition = 0,
|
||||
}: {
|
||||
index: number;
|
||||
viewOffset?: number;
|
||||
viewPosition?: number;
|
||||
}) => {
|
||||
setIsStickingToBottom(false);
|
||||
const offset = offsets[index];
|
||||
if (offset !== undefined) {
|
||||
const newScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
totalHeight - scrollableContainerHeight,
|
||||
offset - viewPosition * scrollableContainerHeight + viewOffset,
|
||||
),
|
||||
);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
|
||||
}
|
||||
},
|
||||
scrollToItem: ({
|
||||
item,
|
||||
viewOffset = 0,
|
||||
viewPosition = 0,
|
||||
}: {
|
||||
item: T;
|
||||
viewOffset?: number;
|
||||
viewPosition?: number;
|
||||
}) => {
|
||||
setIsStickingToBottom(false);
|
||||
const index = data.indexOf(item);
|
||||
if (index !== -1) {
|
||||
const offset = offsets[index];
|
||||
if (offset !== undefined) {
|
||||
const newScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
totalHeight - scrollableContainerHeight,
|
||||
offset - viewPosition * scrollableContainerHeight + viewOffset,
|
||||
),
|
||||
);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
|
||||
}
|
||||
}
|
||||
},
|
||||
getScrollIndex: () => scrollAnchor.index,
|
||||
getScrollState: () => ({
|
||||
scrollTop,
|
||||
scrollHeight: totalHeight,
|
||||
innerHeight: containerHeight,
|
||||
}),
|
||||
}),
|
||||
[
|
||||
offsets,
|
||||
scrollAnchor,
|
||||
totalHeight,
|
||||
getAnchorForScrollTop,
|
||||
data,
|
||||
scrollableContainerHeight,
|
||||
scrollTop,
|
||||
containerHeight,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
overflowY="scroll"
|
||||
overflowX="hidden"
|
||||
scrollTop={scrollTop}
|
||||
scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary}
|
||||
width="100%"
|
||||
height="100%"
|
||||
flexDirection="column"
|
||||
>
|
||||
<Box flexShrink={0} width="100%" flexDirection="column">
|
||||
<Box height={topSpacerHeight} flexShrink={0} />
|
||||
{renderedItems}
|
||||
<Box height={bottomSpacerHeight} flexShrink={0} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const VirtualizedListWithForwardRef = forwardRef(VirtualizedList) as <T>(
|
||||
props: VirtualizedListProps<T> & { ref?: React.Ref<VirtualizedListRef<T>> },
|
||||
) => React.ReactElement;
|
||||
|
||||
export { VirtualizedListWithForwardRef as VirtualizedList };
|
||||
|
||||
VirtualizedList.displayName = 'VirtualizedList';
|
||||
@@ -0,0 +1,9 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<Scrollable /> > matches snapshot 1`] = `
|
||||
"Line 1
|
||||
Line 2
|
||||
Line 3
|
||||
|
||||
"
|
||||
`;
|
||||
@@ -0,0 +1,96 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visible items with 1000 items and 10px height (scroll: +0) 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│Item 0 █│
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 1 │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 2 │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 3 │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visible items with 1000 items and 10px height (scroll: 500) 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│Item 500 │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 501 │
|
||||
│ │
|
||||
│ │
|
||||
│ ▄│
|
||||
│ ▀│
|
||||
│Item 502 │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 503 │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visible items with 1000 items and 10px height (scroll: 999) 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 997 │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 998 │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 999 │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ █│
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<VirtualizedList /> > with 10px height and 100 items > renders only visible items ('scrolled to bottom') 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│Item 92 │
|
||||
│Item 93 │
|
||||
│Item 94 │
|
||||
│Item 95 │
|
||||
│Item 96 │
|
||||
│Item 97 │
|
||||
│Item 98 │
|
||||
│Item 99 █│
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<VirtualizedList /> > with 10px height and 100 items > renders only visible items ('top') 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│Item 0 █│
|
||||
│Item 1 │
|
||||
│Item 2 │
|
||||
│Item 3 │
|
||||
│Item 4 │
|
||||
│Item 5 │
|
||||
│Item 6 │
|
||||
│Item 7 │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
Reference in New Issue
Block a user