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 ( <div style={{ overflowY: "auto", height: 800 }}> <div style={{ height: 40 }}>header</div> - <Virtualizer startMargin={40}> + <Virtualizer startOffset="static"> {Array.from({ length: 1000 }).map((_, i) => ( <div key={i} diff --git a/src/core/scroller.ts b/src/core/scroller.ts index dd48b23e6..efc8b753e 100644 --- a/src/core/scroller.ts +++ b/src/core/scroller.ts @@ -15,7 +15,7 @@ import { ACTION_BEFORE_MANUAL_SMOOTH_SCROLL, ACTION_START_OFFSET_CHANGE, } from "./store"; -import { type ScrollToIndexOpts } from "./types"; +import { type ScrollToIndexOpts, StartOffsetType } from "./types"; import { debounce, timeout, clamp, microtask } from "./utils"; /** @@ -34,6 +34,32 @@ const normalizeOffset = (offset: number, isHorizontal: boolean): number => { } }; +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<typeof createScrollObserver>; * @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<VGridHandle, VGridProps>( const height = getScrollSize(vStore); const width = getScrollSize(hStore); const rootRef = useRef<HTMLDivElement>(null); + const containerRef = useRef<HTMLDivElement>(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<VGridHandle, VGridProps>( } ); 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<VirtualizerHandle, VirtualizerProps>( shift, horizontal: horizontalProp, cache, - startMargin, + startOffset, ssrCount, as: Element = "div", item: ItemElement = "div", @@ -207,13 +213,12 @@ export const Virtualizer = forwardRef<VirtualizerHandle, VirtualizerProps>( 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<VirtualizerHandle, VirtualizerProps>( 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 = <T,>(props: VirtualizerProps<T>): 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<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. */ @@ -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 ( <div style={{ @@ -49,19 +48,63 @@ export const HeaderAndFooter: StoryObj = { overflowAnchor: "none", }} > - <div style={{ backgroundColor: "burlywood", height: headerHeight }}> - header - </div> - <Virtualizer startMargin={headerHeight}>{createRows(1000)}</Virtualizer> + <div style={{ backgroundColor: "burlywood", height: 400 }}>header</div> + <Virtualizer startOffset="static">{createRows(1000)}</Virtualizer> <div style={{ backgroundColor: "steelblue", height: 600 }}>footer</div> </div> ); }, }; +const createColumns = (num: number) => { + return Array.from({ length: num }).map((_, i) => { + return ( + <div + key={i} + style={{ + width: i % 3 === 0 ? 100 : 60, + borderRight: "solid 1px #ccc", + background: "#fff", + }} + > + Column {i} + </div> + ); + }); +}; + +export const HeaderAndFooterHorizontal: StoryObj = { + render: () => { + const ref = useRef<HTMLDivElement>(null); + return ( + <div + ref={ref} + style={{ + width: "100%", + height: "400px", + overflowX: "auto", + // opt out browser's scroll anchoring on header/footer because it will conflict to scroll anchoring of virtualizer + overflowAnchor: "none", + }} + > + <div style={{ display: "flex", height: "100%" }}> + <div style={{ backgroundColor: "burlywood", minWidth: 400 }}> + header + </div> + <Virtualizer horizontal startOffset="static" scrollRef={ref}> + {createColumns(1000)} + </Virtualizer> + <div style={{ backgroundColor: "steelblue", minWidth: 600 }}> + footer + </div> + </div> + </div> + ); + }, +}; + export const StickyHeaderAndFooter: StoryObj = { render: () => { - const headerHeight = 40; return ( <div style={{ @@ -76,14 +119,14 @@ export const StickyHeaderAndFooter: StoryObj = { style={{ position: "sticky", backgroundColor: "burlywood", - height: headerHeight, + height: 40, top: 0, zIndex: 1, }} > header </div> - <Virtualizer startMargin={headerHeight}>{createRows(1000)}</Virtualizer> + <Virtualizer startOffset="static">{createRows(1000)}</Virtualizer> <div style={{ position: "sticky", @@ -99,11 +142,40 @@ export const StickyHeaderAndFooter: StoryObj = { }, }; +export const Padding: StoryObj = { + render: () => { + const ref = useRef<HTMLDivElement>(null); + + return ( + <div + ref={ref} + style={{ + width: "100%", + height: "100vh", + overflowY: "auto", + // opt out browser's scroll anchoring on header/footer because it will conflict to scroll anchoring of virtualizer + overflowAnchor: "none", + }} + > + <div + style={{ + paddingTop: 400, + paddingBottom: 400, + }} + > + <Virtualizer scrollRef={ref} startOffset="static"> + {createRows(1000)} + </Virtualizer> + </div> + </div> + ); + }, +}; + export const Nested: StoryObj = { render: () => { const ref = useRef<HTMLDivElement>(null); - const outerPadding = 40; - const innerPadding = 60; + return ( <div ref={ref} @@ -115,12 +187,9 @@ export const Nested: StoryObj = { overflowAnchor: "none", }} > - <div style={{ backgroundColor: "burlywood", padding: outerPadding }}> - <div style={{ backgroundColor: "steelblue", padding: innerPadding }}> - <Virtualizer - scrollRef={ref} - startMargin={outerPadding + innerPadding} - > + <div style={{ backgroundColor: "burlywood", padding: 40 }}> + <div style={{ backgroundColor: "steelblue", padding: 60 }}> + <Virtualizer scrollRef={ref} startOffset="static"> {createRows(1000)} </Virtualizer> </div> @@ -179,8 +248,6 @@ export const BiDirectionalInfiniteScrolling: StoryObj = { ready.current = true; }, []); - const spinnerHeight = 100; - return ( <div style={{ @@ -190,14 +257,11 @@ export const BiDirectionalInfiniteScrolling: StoryObj = { overflowAnchor: "none", }} > - <Spinner - height={spinnerHeight} - style={startFetching ? undefined : { visibility: "hidden" }} - /> + <Spinner style={startFetching ? undefined : { visibility: "hidden" }} /> <Virtualizer ref={ref} + startOffset="static" shift={shifting ? true : false} - startMargin={spinnerHeight} onRangeChange={async (start, end) => { if (!ready.current) return; if (end + THRESHOLD > count && endFetchedCountRef.current < count) { @@ -219,10 +283,7 @@ export const BiDirectionalInfiniteScrolling: StoryObj = { > {items} </Virtualizer> - <Spinner - height={spinnerHeight} - style={endFetching ? undefined : { visibility: "hidden" }} - /> + <Spinner style={endFetching ? undefined : { visibility: "hidden" }} /> </div> ); }, @@ -349,12 +410,7 @@ export const TableElement: StoryObj = { overflow: "auto", }} > - <Virtualizer - count={1000} - as={Table} - item="tr" - startMargin={TABLE_HEADER_HEIGHT} - > + <Virtualizer count={1000} as={Table} item="tr" unbound> {(i) => ( <Fragment key={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 ( <> <div style={{ ...style, - height: height, + height: 100, display: "flex", alignItems: "center", justifyContent: "center", diff --git a/stories/vue/HeaderAndFooter.vue b/stories/vue/HeaderAndFooter.vue index 3ee0766b0..bb50b7e57 100644 --- a/stories/vue/HeaderAndFooter.vue +++ b/stories/vue/HeaderAndFooter.vue @@ -5,8 +5,6 @@ const sizes = [20, 40, 180, 77]; const createItem = (i: number) => ({ index: i, size: sizes[i % 4] + 'px' }) const data = Array.from({ length: 1000 }).map((_, i) => createItem(i)); - -const headerHeight = 400; </script> <template> @@ -17,10 +15,10 @@ const headerHeight = 400; // opt out browser's scroll anchoring on header/footer because it will conflict to scroll anchoring of virtualizer overflowAnchor: 'none' }"> - <div :style="{ backgroundColor: 'burlywood', height: headerHeight + 'px' }"> + <div :style="{ backgroundColor: 'burlywood', height: '400px' }"> header </div> - <Virtualizer :data="data" #default="item" :startMargin="headerHeight"> + <Virtualizer :data="data" #default="item" :startOffset="'static'"> <div :key="item.index" :style="{ height: item.size, background: 'white', borderBottom: 'solid 1px #ccc' }"> {{ item.index }} </div> diff --git a/stories/vue/Nested.vue b/stories/vue/Nested.vue index b957e349b..8cadf77c7 100644 --- a/stories/vue/Nested.vue +++ b/stories/vue/Nested.vue @@ -7,8 +7,6 @@ const createItem = (i: number) => ({ index: i, size: sizes[i % 4] + 'px' }) const data = Array.from({ length: 1000 }).map((_, i) => createItem(i)); -const outerPadding = 40; -const innerPadding = 60; const scrollRef = ref<HTMLElement>(); </script> @@ -20,9 +18,9 @@ const scrollRef = ref<HTMLElement>(); // opt out browser's scroll anchoring on header/footer because it will conflict to scroll anchoring of virtualizer overflowAnchor: 'none' }"> - <div :style="{ backgroundColor: 'burlywood', padding: outerPadding + 'px' }"> - <div :style="{ backgroundColor: 'steelblue', padding: innerPadding + 'px' }"> - <Virtualizer :data="data" #default="item" :scrollRef="scrollRef" :startMargin="outerPadding + innerPadding"> + <div :style="{ backgroundColor: 'burlywood', padding: '60px' }"> + <div :style="{ backgroundColor: 'steelblue', padding: '40px' }"> + <Virtualizer :data="data" #default="item" :scrollRef="scrollRef" :startOffset="'static'"> <div :key="item.index" :style="{ height: item.size, background: 'white', borderBottom: 'solid 1px #ccc' }"> {{ item.index }} </div>