diff --git a/src/FillSpace.jsx b/src/FillSpace.jsx index 85d9dec..7d33540 100644 --- a/src/FillSpace.jsx +++ b/src/FillSpace.jsx @@ -20,6 +20,7 @@ import { alpha } from '@mui/material/styles'; * @prop {number} [totalCount] - Total items in unfiltered dataset. When unfiltered count <= pageSize, pagination header is removed entirely. When filtering, the header height is preserved to prevent layout shift. * @prop {number} [unfilteredCount] - Alias for totalCount. * @prop {(page: number) => void} [onPageChange] - Called on page change. + * @prop {boolean} [virtualScrollbar=false] - Enables smooth continuous virtual scrolling with a scrollbar synchronized with the pagination controls. * @prop {boolean} [debug=false] - Dashed outline for layout debugging. * @prop {object} [sx] - Extra styles. */ @@ -56,6 +57,10 @@ function renderProbeElement(renderItem) { return ; } +const MIN_THUMB_HEIGHT = 48; +const RACEWAY_WIDTH = 16; +const RACEWAY_BOTTOM_MARGIN = 8; + class FillSpace extends Component { constructor(props) { super(props); @@ -66,12 +71,20 @@ class FillSpace extends Component { viewportHeight: null, pageSize: Math.min(maxSize, minSize), currentPage: 1, + scrollTop: 0, + isDragging: false, }; this.ref = null; + this.trackRef = null; + this.thumbRef = null; this.probeRef = null; this.wheelAcc = 0; this.observer = null; this.raf = null; + this.scrollRaf = null; + this.pendingScrollTop = 0; + this.dragStartY = 0; + this.dragStartScrollTop = 0; const explicitCount = props.unfilteredCount ?? props.totalCount; this.maxSeenCount = explicitCount != null @@ -138,20 +151,122 @@ class FillSpace extends Component { }); } } + if ( + this.props.virtualScrollbar && + (prevProps.items !== this.props.items || + prevProps.totalCount !== this.props.totalCount || + prevProps.unfilteredCount !== this.props.unfilteredCount) + ) { + const explicitTotal = this.props.unfilteredCount ?? this.props.totalCount; + const total = + explicitTotal != null + ? Number(explicitTotal) + : Array.isArray(this.props.items) + ? this.props.items.length + : 0; + const maxScrollTop = Math.max( + 0, + total * this.state.unitHeight - (this.state.viewportHeight || 0), + ); + if (this.state.scrollTop > maxScrollTop) { + if (this.ref && this.ref.scrollTop !== maxScrollTop) { + this.ref.scrollTop = maxScrollTop; + } + this.setState({ scrollTop: maxScrollTop }); + } + } } componentWillUnmount() { + if (this.state.isDragging) { + document.body.style.userSelect = ''; + document.body.style.cursor = ''; + window.removeEventListener('mousemove', this._onThumbDragMove); + window.removeEventListener('mouseup', this._onThumbDragEnd); + } window.removeEventListener('resize', this._onViewportChange); if (this.raf) { cancelAnimationFrame(this.raf); this.raf = null; } + if (this.scrollRaf) { + cancelAnimationFrame(this.scrollRaf); + this.scrollRaf = null; + } if (this.observer) { this.observer.disconnect(); this.observer = null; } } + _onThumbMouseDown = (e) => { + e.preventDefault(); + e.stopPropagation(); + this.dragStartY = e.clientY; + this.dragStartScrollTop = this.state.scrollTop; + this.setState({ isDragging: true }); + document.body.style.userSelect = 'none'; + document.body.style.cursor = 'grabbing'; + window.addEventListener('mousemove', this._onThumbDragMove); + window.addEventListener('mouseup', this._onThumbDragEnd); + }; + + _onThumbDragMove = (e) => { + if (!this.state.isDragging) return; + const deltaY = e.clientY - this.dragStartY; + const { viewportHeight, unitHeight } = this.state; + const { items, totalCount, unfilteredCount } = this.props; + const explicitTotal = unfilteredCount ?? totalCount; + const total = explicitTotal != null ? Number(explicitTotal) : (items?.length || 0); + const totalHeight = total * unitHeight; + const maxScrollTop = Math.max(0, totalHeight - (viewportHeight || 0)); + if (maxScrollTop <= 0) return; + const racewayBottom = Math.max(0, Number(this.props.racewayMarginBottom ?? RACEWAY_BOTTOM_MARGIN) || 0); + const trackHeight = Math.max(0, (viewportHeight || 0) - racewayBottom); + if (trackHeight <= 0) return; + const thumbHeight = Math.max(MIN_THUMB_HEIGHT, Math.min(trackHeight, (viewportHeight / totalHeight) * trackHeight)); + const maxThumbTravel = trackHeight - thumbHeight; + if (maxThumbTravel <= 0) return; + const scrollDelta = (deltaY / maxThumbTravel) * maxScrollTop; + const nextScrollTop = Math.max(0, Math.min(maxScrollTop, this.dragStartScrollTop + scrollDelta)); + if (this.ref) { + this.ref.scrollTop = nextScrollTop; + } + this.setState({ scrollTop: nextScrollTop }); + }; + + _onThumbDragEnd = () => { + document.body.style.userSelect = ''; + document.body.style.cursor = ''; + window.removeEventListener('mousemove', this._onThumbDragMove); + window.removeEventListener('mouseup', this._onThumbDragEnd); + this.setState({ isDragging: false }); + }; + + _handleTrackMouseDown = (e) => { + if (e.target === this.thumbRef || this.thumbRef?.contains(e.target)) return; + if (!this.trackRef) return; + const trackRect = this.trackRef.getBoundingClientRect(); + const clickY = e.clientY - trackRect.top; + const { viewportHeight, unitHeight } = this.state; + const { items, totalCount, unfilteredCount } = this.props; + const explicitTotal = unfilteredCount ?? totalCount; + const total = explicitTotal != null ? Number(explicitTotal) : (items?.length || 0); + const totalHeight = total * unitHeight; + const maxScrollTop = Math.max(0, totalHeight - (viewportHeight || 0)); + if (maxScrollTop <= 0) return; + const trackHeight = trackRect.height; + if (trackHeight <= 0) return; + const thumbHeight = Math.max(MIN_THUMB_HEIGHT, Math.min(trackHeight, (viewportHeight / totalHeight) * trackHeight)); + const maxThumbTravel = trackHeight - thumbHeight; + const targetThumbTop = Math.max(0, Math.min(maxThumbTravel, clickY - thumbHeight / 2)); + const targetScrollTop = maxThumbTravel > 0 ? (targetThumbTop / maxThumbTravel) * maxScrollTop : 0; + if (this.ref) { + this.ref.scrollTop = targetScrollTop; + } + this.setState({ scrollTop: targetScrollTop }); + }; + _onViewportChange = () => { if (this.raf) return; this.raf = requestAnimationFrame(() => { @@ -195,6 +310,21 @@ class FillSpace extends Component { this._viewportMeasure(); }; + scrollToTop = () => { + if (this.ref) { + this.ref.scrollTop = 0; + } + this.setState({ scrollTop: 0, currentPage: 1 }); + }; + + scrollToIndex = (index) => { + if (this.ref && this.state.unitHeight > 0) { + const top = Math.max(0, index * this.state.unitHeight); + this.ref.scrollTop = top; + this.setState({ scrollTop: top }); + } + }; + /** Only measures the height of the `renderItem` probe. Nothing else. */ _probeMeasure() { const firstRow = this.ref?.querySelector('[data-fill-row]'); @@ -247,6 +377,36 @@ class FillSpace extends Component { } } + _handleScroll = (event) => { + const st = event.currentTarget.scrollTop; + this.pendingScrollTop = st; + if (this.scrollRaf) return; + this.scrollRaf = requestAnimationFrame(() => { + this.scrollRaf = null; + const nextSt = this.pendingScrollTop; + if (nextSt != null && nextSt !== this.state.scrollTop) { + const { unitHeight, pageSize } = this.state; + const currentFirst = unitHeight > 0 ? Math.floor(nextSt / unitHeight) : 0; + const newPage = pageSize > 0 ? Math.floor(currentFirst / pageSize) + 1 : 1; + const updates = { scrollTop: nextSt }; + if (newPage !== this.state.currentPage) { + updates.currentPage = newPage; + const { items, onPageChange, totalCount, unfilteredCount } = this.props; + const explicitTotal = unfilteredCount ?? totalCount; + const total = + explicitTotal != null + ? Number(explicitTotal) + : Array.isArray(items) + ? items.length + : 0; + const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize))); + onPageChange?.(newPage, { pageCount, pageSize, total }); + } + this.setState(updates); + } + }); + }; + _handleWheel = (event) => { const { items, totalCount, unfilteredCount } = this.props; if (!Array.isArray(items)) return; @@ -272,7 +432,7 @@ class FillSpace extends Component { }; _goToPage = (page) => { - const { items, onPageChange, totalCount, unfilteredCount } = this.props; + const { items, onPageChange, totalCount, unfilteredCount, virtualScrollbar } = this.props; const explicitTotal = unfilteredCount ?? totalCount; const total = explicitTotal != null @@ -282,6 +442,16 @@ class FillSpace extends Component { : 0; const pageCount = Math.max(1, Math.ceil(total / Math.max(1, this.state.pageSize))); const next = Math.min(Math.max(1, Number(page) || 1), pageCount); + if (virtualScrollbar) { + const targetScrollTop = (next - 1) * this.state.pageSize * this.state.unitHeight; + if (this.ref) { + this.ref.scrollTop = targetScrollTop; + } + if (next === this.state.currentPage && targetScrollTop === this.state.scrollTop) return; + this.setState({ currentPage: next, scrollTop: targetScrollTop }); + onPageChange?.(next, { pageCount, pageSize: this.state.pageSize, total }); + return; + } if (next === this.state.currentPage) return; this.setState({ currentPage: next }); onPageChange?.(next, { pageCount, pageSize: this.state.pageSize, total }); @@ -303,6 +473,8 @@ class FillSpace extends Component { listRef, onPageChange, onWheel, + virtualScrollbar = false, + racewayMarginBottom, debug = false, items, renderItem, @@ -314,7 +486,7 @@ class FillSpace extends Component { // FillSpace owns current page, wheel-pagination and the pagination header. // Caller feeds `items` plus one row element via `renderItem` (component // type or callback). `items` may be undefined while data is loading. - const { pageSize, currentPage, viewportHeight } = this.state; + const { pageSize, currentPage, viewportHeight, unitHeight, scrollTop } = this.state; const list = Array.isArray(items) ? items : []; const explicitTotal = unfilteredCount ?? totalCount; const total = explicitTotal != null ? Number(explicitTotal) : list.length; @@ -322,9 +494,62 @@ class FillSpace extends Component { const needsPaginationContainer = fullTotal > pageSize; const showPaginationControls = total > pageSize; const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize))); - const safePage = Math.min(Math.max(1, currentPage), pageCount); - const offset = (safePage - 1) * pageSize; - const pageItems = list.slice(offset, offset + pageSize); + + const currentFirstIndex = + virtualScrollbar && unitHeight > 0 + ? Math.min(Math.max(0, total - 1), Math.floor(scrollTop / unitHeight)) + : (Math.min(Math.max(1, currentPage), pageCount) - 1) * pageSize; + const safePage = + virtualScrollbar && pageSize > 0 + ? Math.min(pageCount, Math.floor(currentFirstIndex / pageSize) + 1) + : Math.min(Math.max(1, currentPage), pageCount); + + const countStart = total === 0 ? 0 : currentFirstIndex + 1; + const countEnd = Math.min(total, currentFirstIndex + pageSize); + + let renderedItems; + let itemOffset; + let paddingTop = 0; + let paddingBottom = 0; + let totalContentHeight; + + if (virtualScrollbar && unitHeight > 0) { + const overscan = 3; + const rawStartIndex = Math.floor(scrollTop / unitHeight); + const startIndex = Math.max(0, rawStartIndex - overscan); + const visibleCount = Math.ceil((viewportHeight || 0) / unitHeight); + const endIndex = Math.min(total, rawStartIndex + visibleCount + overscan); + renderedItems = list.slice(startIndex, endIndex); + itemOffset = startIndex; + paddingTop = startIndex * unitHeight; + paddingBottom = Math.max(0, (total - endIndex) * unitHeight); + totalContentHeight = total * unitHeight; + } else { + const offset = (safePage - 1) * pageSize; + renderedItems = list.slice(offset, offset + pageSize); + itemOffset = offset; + } + + const canScroll = + virtualScrollbar && + viewportHeight > 0 && + unitHeight > 0 && + total * unitHeight > viewportHeight; + + const racewayBottom = Math.max(0, Number(racewayMarginBottom ?? RACEWAY_BOTTOM_MARGIN) || 0); + + let thumbHeight = 0; + let thumbTop = 0; + + if (canScroll) { + const trackHeight = Math.max(0, (viewportHeight || 0) - racewayBottom); + const fullHeight = total * unitHeight; + thumbHeight = Math.max(MIN_THUMB_HEIGHT, Math.min(trackHeight, (viewportHeight / fullHeight) * trackHeight)); + const maxScrollTop = Math.max(0, fullHeight - viewportHeight); + const maxThumbTravel = trackHeight - thumbHeight; + thumbTop = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTravel : 0; + } + // Hidden data-free probe: measures `renderItem` itself (no data). const probe = renderProbeElement(renderItem); @@ -356,8 +581,7 @@ class FillSpace extends Component { noWrap sx={{ flexShrink: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }} > - {total === 0 ? '0' : `${offset + 1}–${Math.min(offset + pageSize, total)}`} von{' '} - {total} + {total === 0 ? '0' : `${countStart}–${countEnd}`} von {total} { - this.ref = el; - }} - onWheel={(e) => { - this._handleWheel(e); - onWheel?.(e); - }} {...rest} sx={[ { position: 'relative', height: viewportHeight ?? 'auto', - overflow: 'hidden', width: '100%', - // Keep wheel ticks from scrolling the page behind the list. - // (No preventDefault needed — React wheel is passive.) overscrollBehavior: 'contain', }, + sx, debug ? (theme) => ({ outline: `2px dashed ${theme.palette.primary.main}`, }) : undefined, - sx, ]} > - - - {pageItems.map((item, i) => ( - - {renderRowElement(renderItem, item, offset + i)} - - ))} - - - {probe != null ? ( + { + this.ref = el; + }} + onScroll={virtualScrollbar ? this._handleScroll : undefined} + onWheel={(e) => { + if (!virtualScrollbar) { + this._handleWheel(e); + } + onWheel?.(e); + }} + sx={{ + position: 'relative', + height: '100%', + width: '100%', + overflowY: virtualScrollbar ? 'auto' : 'hidden', + overflowX: 'hidden', + scrollbarWidth: 'none', + '&::-webkit-scrollbar': { display: 'none' }, + }} + > { - this.probeRef = el; - }} - aria-hidden sx={{ - position: 'absolute', - left: 0, - top: 0, width: '100%', - visibility: 'hidden', - pointerEvents: 'none', + pr: canScroll ? `${RACEWAY_WIDTH + 8}px` : 0, + boxSizing: 'border-box', + ...(virtualScrollbar && unitHeight > 0 + ? { + paddingTop: `${paddingTop}px`, + paddingBottom: `${paddingBottom}px`, + minHeight: totalContentHeight, + } + : {}), }} > - - - {probe} - + + {renderedItems.map((item, i) => ( + + {renderRowElement(renderItem, item, itemOffset + i)} + + ))} + {probe != null ? ( + { + this.probeRef = el; + }} + aria-hidden + sx={{ + position: 'absolute', + left: 0, + top: 0, + width: '100%', + visibility: 'hidden', + pointerEvents: 'none', + }} + > + + + {probe} + + + + ) : null} + + + {canScroll ? ( + { + this.trackRef = el; + }} + onMouseDown={this._handleTrackMouseDown} + sx={{ + position: 'absolute', + top: 0, + right: 3, + bottom: `${racewayBottom}px`, + width: RACEWAY_WIDTH, + zIndex: 5, + cursor: 'pointer', + borderRadius: 8, + bgcolor: (theme) => alpha(theme.palette.text.primary, 0.04), + transition: 'background-color 0.15s ease', + '&:hover': { + bgcolor: (theme) => alpha(theme.palette.text.primary, 0.09), + }, + }} + > + { + this.thumbRef = el; + }} + onMouseDown={this._onThumbMouseDown} + sx={{ + position: 'absolute', + top: thumbTop, + height: thumbHeight, + left: 2, + right: 2, + borderRadius: 6, + bgcolor: (theme) => + this.state.isDragging + ? alpha(theme.palette.primary.main, 0.7) + : alpha(theme.palette.text.primary, 0.3), + cursor: this.state.isDragging ? 'grabbing' : 'grab', + transition: this.state.isDragging ? 'none' : 'background-color 0.15s ease', + '&:hover': { + bgcolor: (theme) => + this.state.isDragging + ? alpha(theme.palette.primary.main, 0.7) + : alpha(theme.palette.text.primary, 0.55), + }, + }} + /> + ) : null} {debug ? ( diff --git a/src/dev/mock-pages/FillSpaceMockPage.jsx b/src/dev/mock-pages/FillSpaceMockPage.jsx index 4984765..8907e95 100644 --- a/src/dev/mock-pages/FillSpaceMockPage.jsx +++ b/src/dev/mock-pages/FillSpaceMockPage.jsx @@ -34,6 +34,7 @@ class FillSpaceMockPage extends Component { this.ex1 = { header: createRef(), footer: createRef() }; this.ex2 = { header: createRef(), footer: createRef() }; this.ex3 = { header: createRef(), footer: createRef() }; + this.exVirtual = { header: createRef(), footer: createRef() }; } render() { @@ -138,6 +139,37 @@ class FillSpaceMockPage extends Component { + {/* Example 4: Virtual Scrollbar */} + + + Virtual Scrollbar (smooth continuous scroll with scrollbar) + + + + Header + + + + + + Footer + + + + );