Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
146 lines
4.0 KiB
TypeScript
146 lines
4.0 KiB
TypeScript
import React, {
|
|
forwardRef,
|
|
useCallback,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
|
|
import { cn } from '../../lib/utils';
|
|
import { getFixedSizeVirtualWindow } from './virtualListMath';
|
|
|
|
const DEFAULT_OVERSCAN = 6;
|
|
|
|
export type FixedSizeVirtualListHandle = {
|
|
scrollToIndex: (index: number, align?: 'auto' | 'center') => void;
|
|
};
|
|
|
|
interface FixedSizeVirtualListProps<T> {
|
|
items: T[];
|
|
itemHeight: number;
|
|
className?: string;
|
|
contentClassName?: string;
|
|
overscan?: number;
|
|
getItemKey: (item: T, index: number) => string;
|
|
renderItem: (item: T, index: number) => React.ReactNode;
|
|
}
|
|
|
|
function FixedSizeVirtualListInner<T>(
|
|
{
|
|
items,
|
|
itemHeight,
|
|
className,
|
|
contentClassName,
|
|
overscan = DEFAULT_OVERSCAN,
|
|
getItemKey,
|
|
renderItem,
|
|
}: FixedSizeVirtualListProps<T>,
|
|
ref: React.ForwardedRef<FixedSizeVirtualListHandle>,
|
|
) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [scrollTop, setScrollTop] = useState(0);
|
|
const [viewportHeight, setViewportHeight] = useState(0);
|
|
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
const updateViewportHeight = () => {
|
|
setViewportHeight(container.clientHeight);
|
|
};
|
|
|
|
updateViewportHeight();
|
|
const observer = new ResizeObserver(updateViewportHeight);
|
|
observer.observe(container);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
const {
|
|
startIndex,
|
|
endIndex,
|
|
effectiveScrollTop,
|
|
totalHeight,
|
|
} = getFixedSizeVirtualWindow({
|
|
itemCount: items.length,
|
|
itemHeight,
|
|
scrollTop,
|
|
viewportHeight,
|
|
overscan,
|
|
});
|
|
|
|
// Sync the DOM scroll position when content shrinks (filter / data change).
|
|
// Layout already uses effectiveScrollTop so the list never blanks for a frame.
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
if (container.scrollTop !== effectiveScrollTop) {
|
|
container.scrollTop = effectiveScrollTop;
|
|
}
|
|
if (scrollTop !== effectiveScrollTop) {
|
|
setScrollTop(effectiveScrollTop);
|
|
}
|
|
}, [effectiveScrollTop, scrollTop]);
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
scrollToIndex: (index: number, align = 'auto') => {
|
|
const container = containerRef.current;
|
|
if (!container || index < 0 || index >= items.length) return;
|
|
|
|
const itemTop = index * itemHeight;
|
|
const itemBottom = itemTop + itemHeight;
|
|
const viewTop = container.scrollTop;
|
|
const viewBottom = viewTop + container.clientHeight;
|
|
|
|
if (align === 'center') {
|
|
container.scrollTop = Math.max(
|
|
0,
|
|
itemTop - (container.clientHeight - itemHeight) / 2,
|
|
);
|
|
} else if (itemTop < viewTop) {
|
|
container.scrollTop = itemTop;
|
|
} else if (itemBottom > viewBottom) {
|
|
container.scrollTop = itemBottom - container.clientHeight;
|
|
}
|
|
setScrollTop(container.scrollTop);
|
|
},
|
|
}), [itemHeight, items.length]);
|
|
|
|
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
|
setScrollTop(event.currentTarget.scrollTop);
|
|
}, []);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={cn('h-full overflow-y-auto overflow-x-hidden', className)}
|
|
onScroll={handleScroll}
|
|
>
|
|
<div
|
|
className={cn('relative w-full', contentClassName)}
|
|
style={{ height: totalHeight || undefined, minHeight: items.length === 0 ? 0 : totalHeight }}
|
|
>
|
|
{items.slice(startIndex, endIndex).map((item, offset) => {
|
|
const index = startIndex + offset;
|
|
return (
|
|
<div
|
|
key={getItemKey(item, index)}
|
|
className="absolute left-0 right-0"
|
|
style={{
|
|
top: index * itemHeight,
|
|
height: itemHeight,
|
|
}}
|
|
>
|
|
{renderItem(item, index)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const FixedSizeVirtualList = forwardRef(FixedSizeVirtualListInner) as <T>(
|
|
props: FixedSizeVirtualListProps<T> & { ref?: React.ForwardedRef<FixedSizeVirtualListHandle> },
|
|
) => React.ReactElement | null;
|