diff --git a/README.md b/README.md index 2a251271b..7b7fcec8c 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ export const App = () => { return (
header
- + {Array.from({ length: 1000 }).map((_, i) => (
{ } }; +const calcOffsetToViewport = ( + node: HTMLElement, + viewport: HTMLElement, + isHorizontal: boolean, + offset: number = 0 +): number => { + // TODO calc offset only when it changes (maybe impossible) + const offsetSum = + offset + + (isHorizontal && isRTLDocument() + ? viewport.offsetWidth - node.offsetLeft - node.offsetWidth + : node[isHorizontal ? "offsetLeft" : "offsetTop"]); + + const parent = node.offsetParent; + if (node === viewport || !parent) { + return offsetSum; + } + + return calcOffsetToViewport( + parent as HTMLElement, + viewport, + isHorizontal, + offsetSum + ); +}; + const createScrollObserver = ( store: VirtualStore, viewport: HTMLElement | Window, @@ -121,6 +147,10 @@ const createScrollObserver = ( } }; + if (getStartOffset) { + store._update(ACTION_START_OFFSET_CHANGE, getStartOffset()); + } + viewport.addEventListener("scroll", onScroll); viewport.addEventListener("wheel", onWheel, { passive: true }); viewport.addEventListener("touchstart", onTouchStart, { passive: true }); @@ -159,7 +189,10 @@ type ScrollObserver = ReturnType; * @internal */ export type Scroller = { - _observe: (viewportElement: HTMLElement) => void; + _observe: ( + viewportElement: HTMLElement, + containerElement: HTMLElement + ) => void; _dispose(): void; _scrollTo: (offset: number) => void; _scrollBy: (offset: number) => void; @@ -172,7 +205,8 @@ export type Scroller = { */ export const createScroller = ( store: VirtualStore, - isHorizontal: boolean + isHorizontal: boolean, + startOffset?: StartOffsetType ): Scroller => { let viewportElement: HTMLElement | undefined; let scrollObserver: ScrollObserver | undefined; @@ -264,9 +298,24 @@ export const createScroller = ( }; return { - _observe(viewport) { + _observe(viewport, container) { viewportElement = viewport; + let getStartOffset: (() => number) | undefined; + if (startOffset === "dynamic") { + getStartOffset = () => + calcOffsetToViewport(container, viewport, isHorizontal); + } else if (startOffset === "static") { + const staticStartOffset = calcOffsetToViewport( + container, + viewport, + isHorizontal + ); + getStartOffset = () => staticStartOffset; + } else if (typeof startOffset === "number") { + getStartOffset = () => startOffset; + } + scrollObserver = createScrollObserver( store, viewport, @@ -293,7 +342,8 @@ export const createScroller = ( } else { viewport[scrollOffsetKey] += jump; } - } + }, + getStartOffset ); }, _dispose() { @@ -371,33 +421,6 @@ export const createWindowScroller = ( const window = getCurrentWindow(document); const documentBody = document.body; - const calcOffsetToViewport = ( - node: HTMLElement, - viewport: HTMLElement, - isHorizontal: boolean, - offset: number = 0 - ): number => { - // TODO calc offset only when it changes (maybe impossible) - const offsetKey = isHorizontal ? "offsetLeft" : "offsetTop"; - const offsetSum = - offset + - (isHorizontal && isRTLDocument() - ? window.innerWidth - node[offsetKey] - node.offsetWidth - : node[offsetKey]); - - const parent = node.offsetParent; - if (node === viewport || !parent) { - return offsetSum; - } - - return calcOffsetToViewport( - parent as HTMLElement, - viewport, - isHorizontal, - offsetSum - ); - }; - scrollObserver = createScrollObserver( store, window, @@ -429,7 +452,10 @@ export const createWindowScroller = ( * @internal */ export type GridScroller = { - _observe: (viewportElement: HTMLElement) => void; + _observe: ( + viewportElement: HTMLElement, + containerElement: HTMLElement + ) => void; _dispose(): void; _scrollTo: (offsetX: number, offsetY: number) => void; _scrollBy: (offsetX: number, offsetY: number) => void; @@ -447,9 +473,9 @@ export const createGridScroller = ( const vScroller = createScroller(vStore, false); const hScroller = createScroller(hStore, true); return { - _observe(viewportElement) { - vScroller._observe(viewportElement); - hScroller._observe(viewportElement); + _observe(viewportElement, containerElement) { + vScroller._observe(viewportElement, containerElement); + hScroller._observe(viewportElement, containerElement); }, _dispose() { vScroller._dispose(); diff --git a/src/core/store.ts b/src/core/store.ts index af183c377..48a6e039b 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -140,13 +140,13 @@ export const createVirtualStore = ( itemSize: number = 40, ssrCount: number = 0, cacheSnapshot?: CacheSnapshot | undefined, - shouldAutoEstimateItemSize: boolean = false, - startSpacerSize: number = 0 + shouldAutoEstimateItemSize: boolean = false ): VirtualStore => { let isSSR = !!ssrCount; let stateVersion: StateVersion = []; let viewportSize = 0; let scrollOffset = 0; + let startOffset = 0; let jumpCount = 0; let jump = 0; let pendingJump = 0; @@ -165,7 +165,7 @@ export const createVirtualStore = ( cacheSnapshot as unknown as InternalCacheSnapshot | undefined ); const subscribers = new Set<[number, Subscriber]>(); - const getRelativeScrollOffset = () => scrollOffset - startSpacerSize; + const getRelativeScrollOffset = () => scrollOffset - startOffset; const getRange = (offset: number) => { return computeRange(cache, offset, _prevRange[0], viewportSize); }; @@ -240,7 +240,7 @@ export const createVirtualStore = ( return viewportSize; }, _getStartSpacerSize() { - return startSpacerSize; + return startOffset; }, _getTotalSize: getTotalSize, _getJumpCount() { @@ -418,7 +418,7 @@ export const createVirtualStore = ( break; } case ACTION_START_OFFSET_CHANGE: { - startSpacerSize = payload; + startOffset = payload; break; } case ACTION_MANUAL_SCROLL: { diff --git a/src/core/types.ts b/src/core/types.ts index 956c2434e..d532ca7d8 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -42,3 +42,5 @@ export interface ScrollToIndexOpts { */ offset?: number; } + +export type StartOffsetType = "dynamic" | "static" | number; diff --git a/src/react/VGrid.tsx b/src/react/VGrid.tsx index bfdea797a..823e39e95 100644 --- a/src/react/VGrid.tsx +++ b/src/react/VGrid.tsx @@ -25,6 +25,7 @@ import { ViewportComponentAttributes } from "./types"; import { flushSync } from "react-dom"; import { isRTLDocument } from "../core/environment"; import { useRerender } from "./useRerender"; + const genKey = (i: number, j: number) => `${i}-${j}`; /** @@ -250,9 +251,11 @@ export const VGrid = forwardRef( const height = getScrollSize(vStore); const width = getScrollSize(hStore); const rootRef = useRef(null); + const containerRef = useRef(null); useIsomorphicLayoutEffect(() => { const root = rootRef[refKey]!; + const container = containerRef[refKey]!; // store must be subscribed first because others may dispatch update on init depending on implementation const unsubscribeVStore = vStore._subscribe( UPDATE_VIRTUAL_STATE, @@ -275,7 +278,7 @@ export const VGrid = forwardRef( } ); resizer._observeRoot(root); - scroller._observe(root); + scroller._observe(root, container); return () => { unsubscribeVStore(); unsubscribeHStore(); diff --git a/src/react/Virtualizer.tsx b/src/react/Virtualizer.tsx index 4ff19f6d2..7781c425b 100644 --- a/src/react/Virtualizer.tsx +++ b/src/react/Virtualizer.tsx @@ -24,7 +24,11 @@ import { useStatic } from "./useStatic"; import { useLatestRef } from "./useLatestRef"; import { createResizer } from "../core/resizer"; import { ListItem } from "./ListItem"; -import { CacheSnapshot, ScrollToIndexOpts } from "../core/types"; +import { + CacheSnapshot, + ScrollToIndexOpts, + StartOffsetType, +} from "../core/types"; import { flushSync } from "react-dom"; import { useRerender } from "./useRerender"; import { useChildren } from "./useChildren"; @@ -120,8 +124,10 @@ export interface VirtualizerProps { cache?: CacheSnapshot; /** * If you put an element before virtualizer, you have to define its height with this prop. + * + * TODO */ - startMargin?: number; + startOffset?: StartOffsetType; /** * A prop for SSR. If set, the specified amount of items will be mounted in the initial rendering regardless of the container size until hydrated. */ @@ -178,7 +184,7 @@ export const Virtualizer = forwardRef( shift, horizontal: horizontalProp, cache, - startMargin, + startOffset, ssrCount, as: Element = "div", item: ItemElement = "div", @@ -207,13 +213,12 @@ export const Virtualizer = forwardRef( itemSize, ssrCount, cache, - !itemSize, - startMargin + !itemSize ); return [ _store, createResizer(_store, _isHorizontal), - createScroller(_store, _isHorizontal), + createScroller(_store, _isHorizontal, startOffset), _isHorizontal, ]; }); @@ -281,15 +286,16 @@ export const Virtualizer = forwardRef( onScrollEnd[refKey] && onScrollEnd[refKey](); } ); + const container = containerRef[refKey]!; const assignScrollableElement = (e: HTMLElement) => { resizer._observeRoot(e); - scroller._observe(e); + scroller._observe(e, container); }; if (scrollRef) { // parent's ref doesn't exist when useLayoutEffect is called microtask(() => assignScrollableElement(scrollRef[refKey]!)); } else { - assignScrollableElement(containerRef[refKey]!.parentElement!); + assignScrollableElement(container.parentElement!); } return () => { diff --git a/src/solid/Virtualizer.tsx b/src/solid/Virtualizer.tsx index d0623fc2d..5b3fe70c7 100644 --- a/src/solid/Virtualizer.tsx +++ b/src/solid/Virtualizer.tsx @@ -225,7 +225,7 @@ export const Virtualizer = (props: VirtualizerProps): JSX.Element => { const scrollable = containerRef!.parentElement!; resizer._observeRoot(scrollable); - scroller._observe(scrollable); + scroller._observe(scrollable, containerRef!); onCleanup(() => { if (props.ref) { diff --git a/src/svelte/VList.svelte b/src/svelte/VList.svelte index c8dfde8d6..5ed366857 100644 --- a/src/svelte/VList.svelte +++ b/src/svelte/VList.svelte @@ -123,8 +123,9 @@ ); onMount(() => { + const container = containerRef!; const root = containerRef.parentElement!; - virtualizer[ON_MOUNT](root); + virtualizer[ON_MOUNT](root, container); }); onDestroy(() => { virtualizer[ON_UN_MOUNT](); diff --git a/src/svelte/core.ts b/src/svelte/core.ts index 0ddfbdd4c..9b2a0c86c 100644 --- a/src/svelte/core.ts +++ b/src/svelte/core.ts @@ -73,9 +73,9 @@ export const createVirtualizer = ( ); return { - [ON_MOUNT]: (scrollable: HTMLElement) => { + [ON_MOUNT]: (scrollable: HTMLElement, container: HTMLElement) => { resizer._observeRoot(scrollable); - scroller._observe(scrollable); + scroller._observe(scrollable, container); }, [ON_UN_MOUNT]: () => { unsubscribeStore(); diff --git a/src/vue/Virtualizer.tsx b/src/vue/Virtualizer.tsx index 280490e39..a6d805ed0 100644 --- a/src/vue/Virtualizer.tsx +++ b/src/vue/Virtualizer.tsx @@ -24,7 +24,7 @@ import { } from "../core/store"; import { createResizer } from "../core/resizer"; import { createScroller } from "../core/scroller"; -import { ScrollToIndexOpts } from "../core/types"; +import { ScrollToIndexOpts, StartOffsetType } from "../core/types"; import { ListItem } from "./ListItem"; import { getKey } from "./utils"; import { microtask } from "../core/utils"; @@ -92,8 +92,10 @@ const props = { horizontal: Boolean, /** * If you put an element before virtualizer, you have to define its height with this prop. + * + * TODO */ - startMargin: Number, + startOffset: [String, Number] as PropType, /** * A prop for SSR. If set, the specified amount of items will be mounted in the initial rendering regardless of the container size until hydrated. */ @@ -117,11 +119,10 @@ export const Virtualizer = /*#__PURE__*/ defineComponent({ props.itemSize ?? 40, props.ssrCount, undefined, - !props.itemSize, - props.startMargin + !props.itemSize ); const resizer = createResizer(store, isHorizontal); - const scroller = createScroller(store, isHorizontal); + const scroller = createScroller(store, isHorizontal, props.startOffset); const rerender = ref(store._getStateVersion()); const unsubscribeStore = store._subscribe(UPDATE_VIRTUAL_STATE, () => { @@ -142,15 +143,16 @@ export const Virtualizer = /*#__PURE__*/ defineComponent({ isSSR = false; microtask(() => { + const container = containerRef.value!; const assignScrollableElement = (e: HTMLElement) => { resizer._observeRoot(e); - scroller._observe(e); + scroller._observe(e, container); }; if (props.scrollRef) { // parent's ref doesn't exist when onMounted is called assignScrollableElement(props.scrollRef!); } else { - assignScrollableElement(containerRef.value!.parentElement!); + assignScrollableElement(container.parentElement!); } }); }); diff --git a/stories/react/basics/Virtualizer.stories.tsx b/stories/react/basics/Virtualizer.stories.tsx index fb4a037cf..28afe2920 100644 --- a/stories/react/basics/Virtualizer.stories.tsx +++ b/stories/react/basics/Virtualizer.stories.tsx @@ -38,7 +38,6 @@ const createRows = (num: number) => { export const HeaderAndFooter: StoryObj = { render: () => { - const headerHeight = 400; return (
-
- header -
- {createRows(1000)} +
header
+ {createRows(1000)}
footer
); }, }; +const createColumns = (num: number) => { + return Array.from({ length: num }).map((_, i) => { + return ( +
+ Column {i} +
+ ); + }); +}; + +export const HeaderAndFooterHorizontal: StoryObj = { + render: () => { + const ref = useRef(null); + return ( +
+
+
+ header +
+ + {createColumns(1000)} + +
+ footer +
+
+
+ ); + }, +}; + export const StickyHeaderAndFooter: StoryObj = { render: () => { - const headerHeight = 40; return (
header
- {createRows(1000)} + {createRows(1000)}
{ + const ref = useRef(null); + + return ( +
+
+ + {createRows(1000)} + +
+
+ ); + }, +}; + export const Nested: StoryObj = { render: () => { const ref = useRef(null); - const outerPadding = 40; - const innerPadding = 60; + return (
-
-
- +
+
+ {createRows(1000)}
@@ -179,8 +248,6 @@ export const BiDirectionalInfiniteScrolling: StoryObj = { ready.current = true; }, []); - const spinnerHeight = 100; - return (
- + { if (!ready.current) return; if (end + THRESHOLD > count && endFetchedCountRef.current < count) { @@ -219,10 +283,7 @@ export const BiDirectionalInfiniteScrolling: StoryObj = { > {items} - +
); }, @@ -349,12 +410,7 @@ export const TableElement: StoryObj = { overflow: "auto", }} > - + {(i) => ( {COLUMN_WIDTHS.map((width, j) => ( diff --git a/stories/react/common.tsx b/stories/react/common.tsx index 320fe7ab2..3a60e2fb1 100644 --- a/stories/react/common.tsx +++ b/stories/react/common.tsx @@ -8,17 +8,15 @@ export const delay = (ms: number) => export const Spinner = ({ style, - height = 100, }: { style?: CSSProperties; - height?: number; }) => { return ( <>
({ index: i, size: sizes[i % 4] + 'px' }) const data = Array.from({ length: 1000 }).map((_, i) => createItem(i)); - -const headerHeight = 400;